Author SHA1 Message Date
xavierk 92f952b136 Add backup/restore, daily auto-backup and history CSV/JSON export (Phase E3)
- create_backup writes a zip (manifest with sha256 per file, a VACUUM INTO database snapshot, assets, archive, fonts) atomically. restore_backup validates the manifest, rejects tampered files, path traversal, symlinks and newer schemas, checks the staged DB with integrity_check, then stages a pending restore. The swap runs on the next start before the database opens, moves the current data to backups/pre-restore-<ts>/ and rolls back on any failure.
- Daily automatic backup (migration M9 auto_backup, default on) keeps the newest 14 and never blocks startup.
- Settings Data tab: back up now, restore with confirmation and restart, auto-backup toggle, last backup time.
- History exports the filtered rows as CSV (UTF-8 BOM, CRLF, formula-injection guard) or JSON (voiced.history.v1) with CA-friendly tax columns from a new list_invoice_ledger command.
- Rust round-trip, rejection, swap-rollback and retention tests; vitest for the export and backup helpers.

Adds the zip 8.6.0 and typed-path 0.12.3 crates (pure Rust); package builds must vendor them. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
2026-10-04 18:13:57 +05:30
xavierk 1623b879e2 Add payments with TDS, invoice detail view and filterable History (Phase E2)
- Migration M8 adds payments (cash, TDS, mode, reference). Payment status is derived, never stored: unpaid, partially paid, paid, or overdue. Overpayment is rejected, TDS counts toward settling, and only issued invoices accept payments. list_invoices and get_invoice carry the paid, TDS and balance summary.
- New invoice detail view: archived-PDF preview, payments list, Record payment modal, Export, Duplicate (as a draft), Re-render and Cancel. An invoice with payments cannot be cancelled until they are deleted.
- History gets search, status, financial-year, month and client filters, sortable columns, pagination, footer totals and an FY summary. Cancelled invoices are excluded from totals. The per-row archive-status calls are gone; archiving is on the detail page.
- Rust tests for payments and the list summary; vitest for fiscal-year, money, filter and duplicate helpers.

PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
2026-10-04 17:49:25 +05:30
xavierk 3510cc4260 Add toasts, Settings tabs, Clients page and item presets (Phase E1)
- Global Carbon toast stack replaces scattered inline notices; errors persist until closed.
- Settings is split into eight tabs with one Save and a remembered last tab; Numbering embeds the series settings.
- New Clients page with a DataTable, shared add/edit modal, structured addresses, GST category (informational), per-client notes and payment terms. The composed address string is kept for back-compat. Deleting a client warns about invoice count; issued invoices keep their own snapshot.
- Migration M7 adds the client columns and item_presets; existing clients with a GSTIN are backfilled to registered_regular.
- New invoice form: client ComboBox with type-ahead and inline create, client defaults applied on selection, and an Add from preset control.
- Rust tests for M7 (fresh and v6 upgrade), client validation and CRUD, presets CRUD; vitest for the toast store and new helpers.

PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
2026-10-04 17:29:46 +05:30
xavierk 9e17ba3479 Fix Monolith and Cobalt Stripe closing-block squeeze; guard Monolith flow notes
When only the closing block moved to page 2, the page-1 deficit was shared across every gap, including the closing block's own gaps, which cannot help page 1. Page 1 then came out a few points short and the totals spilled over with the closing block. Monolith and Cobalt Stripe now squeeze only the page-1 gaps in that case, as Marble and Highlighter already do.

Monolith's flow notes block could start in the last few points of a page and be cut into zero-height lines. It now holds 70 pt of room under its label first, as Marble does.

Adds tests for both cases (they failed before the fix) and notes in the template specs. Fingerprint goldens and thumbnails are unchanged.
2026-10-04 16:46:22 +05:30
xavierk afbc0aa56f Add font import for Now, Gotham and Open Sauce One overrides; package fonts for Void
Font import:
- Migration M6 adds user_fonts; files are stored content-addressed under
  the local data dir. New Rust commands inspect, import, list, read and
  remove fonts, refusing WOFF2, collections, variable fonts and
  restricted fsType, and unpacking WOFF1 to plain sfnt.
- Imported fonts register in the render worker as user:<Face> families
  with an italic alias, and are pinned in the frozen render prefs so
  issued invoices can still re-render. Fonts referenced by issued
  invoices are hidden, never deleted.
- Size and tracking compensations tuned for the substitutes apply only
  while the face is not overridden; default output and fingerprint
  goldens are unchanged.
- Settings gets a Fonts section with a licence acknowledgement.

Void packaging:
- Install fonts to /usr/share/fonts/voiced with per-family OFL licences,
  set font_dirs, and add INSTALL/REMOVE scripts that run fc-cache.
  xbps-create carries both scripts (verified in a void-glibc container).
- Fix xbps-create dependency specs in build-xbps.sh.
2026-10-04 13:58:06 +05:30
xavierk 4d18a50753 Stop tracking .commandcode; keep it local via .gitignore 2026-10-04 13:16:31 +05:30
89 changed files with 9349 additions and 444 deletions
View File
+1
View File
@@ -30,3 +30,4 @@ xwin/
# Local agent notes (kept on disk, not in the repo)
CLAUDE.md
handoff.md
.commandcode/
+9 -1
View File
@@ -108,12 +108,20 @@ Launch **Voiced** from the application menu, or run `voiced`. Remove it with
The package installs `/usr/bin/voiced`, a desktop entry and hicolor icons, and depends on
`libwebkit2gtk41`.
**Fonts on Void.** The package also installs the bundled TTF fonts under
`/usr/share/fonts/voiced/<family>/` and each family's OFL licence under
`/usr/share/licenses/voiced/`. The `voiced/` subdirectory avoids file conflicts with Void's own
`font-ibm-plex-ttf` and `noto-fonts-ttf`. The `INSTALL` and `REMOVE` scripts in `packaging/void/`
run `fc-cache -f /usr/share/fonts/voiced` after install and removal. `xbps-create` packs them from
the destdir root (checked with xbps 0.60.7).
### Building through xbps-src
Copy `packaging/void/template` into a `void-packages` checkout and build it the standard way:
```bash
cp -r packaging/void/template void-packages/srcpkgs/voiced/template
mkdir -p void-packages/srcpkgs/voiced
cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE void-packages/srcpkgs/voiced/
cd void-packages && ./xbps-src pkg voiced
```
+2
View File
@@ -156,3 +156,5 @@ Built as `src/pdf/templates/monolith/`: a standalone template (`tokens.ts` the t
- **Elastic gaps.** The 88 pt band under the client (down to 24), the row padding (down to 6), the gaps above BILLED TO and under the client block (down to 14) and the two gaps in the footer (down to 8) give back Letter's missing 50 pt, and the overflow of an invoice that is a little too long for one page. A 2-item tax invoice with a bank block fits one A4 page that way; with 3 rows and a signature image the closing block moves to page 2 (the items and totals stay together on page 1).
- **Amount in words and reverse charge** are the totals' left column (Medium 10.5, x 35..329). **Notes** and the **signature** (an image up to 150 x 45, or the signatory name and designation and the computer-generated line) form the band above the footer's top rule; notes taller than 110 pt flow as their own block before the closing.
- **Footer position.** The closing block ends 24 pt above the "Generated with Voiced" line, so its bottom rule is at y 804 where the reference design's is at 812.
- **Closing moves alone.** When the items and totals fit page 1 only with the gaps squeezed and the closing block moves to page 2, the squeeze is computed from the gaps that help page 1 (band, info gaps, row padding) alone, as in Marble: the footer's own two gaps stay nominal and no longer take a share of the deficit. Before, the share was spread over all gaps including the closing block's, so page 1 came out a few points short and the totals spilled to page 2 as well (A4 with 4 or 5 rows, Letter with 4).
- **Flow notes guard.** The flow-notes block is preceded by a zero-height `TableGuard` asking for 84 pt (14 gap + 70 hold), so it never starts with less than its label and first line of room and collapses into zero-height lines at a page foot; it starts on the next page instead (Marble's guard).
+1
View File
@@ -155,4 +155,5 @@ Built as `src/pdf/templates/cobalt/`: a standalone template (`tokens.ts` the typ
- **Totals.** "Sub Total :", Discount, Taxable Value and the CGST / SGST / IGST rows (labelled "CGST 9% :") end at the label column's right edge (x 417.4 of the design); the amount in words and the reverse-charge line sit under the GRAND TOTAL bar, right-aligned in 9.5 pt grey. The PAYMENT METHOD bar is level with the GRAND TOTAL bar as in the design whenever the invoice still fits its page that way; with a longer stack (a GST invoice has up to five rows against the design's two) it moves up beside the first row, which gives back up to the stack's height (104 pt for five rows), and the bank lines follow it.
- **Closing.** The divider, "Thank you for business with us!" and the terms (the notes; taller than 110 pt they flow as a block of their own before the closing) at the left, the signature at the right, then the icon footer: the footer rule with accents at both ends, and phone, e-mail and address with Carbon icons (26 pt boxes, sky). The footer is part of the closing block, so it is on the last page only, not on every page, and everything in it is audited. The contacts keep their natural width at 32.5 pt apart; when they do not fit, the address wraps into what is left and the phone and e-mail shrink only if the address would get under 120 pt.
- **Elastic gaps** (Letter, and A4 when a one-page invoice overflows by less than the slack): header to client (42 to 26 pt), client to table (30.5 to 16), last row to the totals (9.8 to 4), thank-you to terms (26 to 14) and terms to the footer rule (16 to 8). Type is never scaled.
- **Closing moves alone.** When the items and totals fit page 1 only with the gaps squeezed and the closing block moves to page 2, the squeeze is computed from the gaps that help page 1 (header to client, client to table, last row to totals) alone, as in Marble: the terms and footer gaps stay nominal and no longer take a share of the deficit. Before, the share was spread over all gaps including the closing block's, so page 1 came out a few points short and the totals spilled to page 2 as well (A4 with 7 rows).
- **Margins.** 54.2 pt at the top (the INVOICE line box starts there so its baseline lands at 87.2), 59.55 at the sides and 35.5 plus the 24 pt footer reserve at the foot, which puts the icons' bottom 59.5 pt above the page foot as in the design.
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# xbps action script for 'voiced': $1 = action, $2 = pkgname, $3 = version.
#
# Rebuilds the fontconfig cache for the bundled fonts after install and upgrade.
# The stock x11-fonts trigger (enabled by font_dirs) only runs fc-cache when
# mkfontdir and mkfontscale are installed, so it is not relied on here.
# Never fails the transaction: a missing fc-cache or a cache error is ignored.
case "$1" in
post)
if command -v fc-cache >/dev/null 2>&1; then
fc-cache -f /usr/share/fonts/voiced >/dev/null 2>&1 || :
fi
;;
esac
exit 0
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# xbps action script for 'voiced': $1 = action, $2 = pkgname, $3 = version.
#
# Refreshes the fontconfig cache after the bundled fonts are removed, so stale
# entries for /usr/share/fonts/voiced do not linger.
# Never fails the transaction: a missing fc-cache or a cache error is ignored.
case "$1" in
post)
if command -v fc-cache >/dev/null 2>&1; then
fc-cache -f /usr/share/fonts/voiced >/dev/null 2>&1 || :
fi
;;
esac
exit 0
+22 -1
View File
@@ -54,6 +54,27 @@ install_icon src-tauri/icons/icon.png 512x512
install -Dm644 README.md "$STAGE/usr/share/doc/voiced/README.md"
# Bundled fonts: one subdirectory per family under /usr/share/fonts/voiced, plus
# each family's OFL text. Only *.ttf files are installed (not manifest.json, and
# not the legacy woff/woff2 files that sit directly in public/fonts).
for dir in public/fonts/*/; do
family="$(basename "$dir")"
for ttf in "$dir"*.ttf; do
install -Dm644 "$ttf" "$STAGE/usr/share/fonts/voiced/$family/$(basename "$ttf")"
done
install -Dm644 "${dir}OFL.txt" "$STAGE/usr/share/licenses/voiced/${family}-OFL.txt"
done
# xbps action scripts. xbps-create packs INSTALL and REMOVE found at the root of
# the destdir into the archive (verified: they appear as ./INSTALL and ./REMOVE
# members and are excluded from files.plist), and xbps runs them with $1 set to
# the action. They refresh the fontconfig cache for /usr/share/fonts/voiced.
# Fallback if a future xbps-create stops carrying them: font cache refresh would
# then rely on fontconfig regenerating its cache on first use, or on the xbps-src
# template (which uses the same scripts).
install -Dm755 packaging/void/INSTALL "$STAGE/INSTALL"
install -Dm755 packaging/void/REMOVE "$STAGE/REMOVE"
echo "==> Creating $PKGVER.$ARCH.xbps"
# Kept outside Vite's `dist/` output directory, which is wiped on every build.
OUT="$ROOT/build/void"
@@ -71,7 +92,7 @@ mkdir -p "$REPO"
-l "proprietary" \
-H "https://git.bongbetic.com/xavierk/Voiced" \
-m "Xavier Karma <xavier@bongbetic.com>" \
-D "libwebkit2gtk41>=2.50_1 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils" \
-D "libwebkit2gtk41>=2.50_1 fontconfig>=0 xdg-utils>=0 hicolor-icon-theme>=0 desktop-file-utils>=0" \
-t "office finance" \
--compression zstd \
"$STAGE"
+23 -1
View File
@@ -3,7 +3,9 @@
# Canonical Void Linux package definition, to be built with xbps-src from a
# void-packages checkout:
#
# cp -r packaging/void/template void-packages/srcpkgs/voiced/template
# mkdir -p void-packages/srcpkgs/voiced
# cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE \
# void-packages/srcpkgs/voiced/
# cd void-packages && ./xbps-src pkg voiced
#
# Notes:
@@ -12,6 +14,13 @@
# tarball, mirror it, or drop the sources into the xbps-src masterdir.
# * `checksum=SKIP` avoids pinning a hash that cannot be computed here; replace
# it with the real sha256 once the tarball is published.
# * INSTALL and REMOVE (next to this file) are xbps action scripts that run
# `fc-cache -f /usr/share/fonts/voiced` after install and removal. xbps-src
# picks them up automatically from srcpkgs/voiced/INSTALL and
# srcpkgs/voiced/REMOVE and packs them into the package; the template does
# not install them. Copy them next to the template (see the commands above).
# The stock x11-fonts trigger enabled by font_dirs does not run fc-cache
# unless mkfontdir and mkfontscale are installed, hence these scripts.
# * If the build machine is offline, pre-populate the npm cache and Cargo
# registry in the masterdir before building.
#
@@ -29,6 +38,9 @@ homepage="https://git.bongbetic.com/xavierk/Voiced"
distfiles="https://git.bongbetic.com/xavierk/Voiced/archive/v${version}.tar.gz"
checksum=SKIP
compression="zstd"
# Fonts install under a voiced/ subdirectory so they never conflict with the
# files of Void's own font-ibm-plex-ttf and noto-fonts-ttf packages.
font_dirs="/usr/share/fonts/voiced"
do_build() {
npm install --include=dev --no-audit --no-fund
@@ -51,4 +63,14 @@ do_install() {
vinstall "${wrksrc}/src-tauri/icons/icon.png" 644 \
usr/share/icons/hicolor/512x512/apps voiced.png
vdoc "${wrksrc}/README.md"
# Bundled fonts: one subdirectory per family, plus its OFL text.
# manifest.json and any other non-font file are deliberately skipped.
for dir in "${wrksrc}"/public/fonts/*/; do
family="$(basename "${dir}")"
for ttf in "${dir}"*.ttf; do
vinstall "${ttf}" 644 "usr/share/fonts/voiced/${family}"
done
vlicense "${dir}OFL.txt" "${family}-OFL.txt"
done
}
+21
View File
@@ -4188,6 +4188,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typeid"
version = "1.0.3"
@@ -4290,6 +4296,7 @@ version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"flate2",
"image",
"percent-encoding",
"rusqlite",
@@ -4306,6 +4313,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.21",
"uuid",
"zip",
]
[[package]]
@@ -5181,6 +5189,19 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap 2.14.2",
"memchr",
"typed-path",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
+2
View File
@@ -27,6 +27,8 @@ uuid = { version = "1", features = ["v4"] }
thiserror = "2"
base64 = "0.22"
sha2 = "0.10"
flate2 = "1"
zip = { version = "8", default-features = false, features = ["deflate-flate2"] }
percent-encoding = "2"
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
File diff suppressed because it is too large Load Diff
+298 -25
View File
@@ -1,8 +1,16 @@
use crate::gst;
use crate::models::Client;
use crate::AppState;
use rusqlite::{params, Row};
use rusqlite::{params, Connection, Row};
use tauri::State;
const CATEGORIES: &[&str] = &["registered_regular", "unregistered", "composition", "sez", "overseas"];
const CLIENT_COLS: &str = "c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at,
c.address_line1, c.address_line2, c.city, c.pincode, c.gst_category, c.default_notes,
c.payment_terms_days,
(SELECT COUNT(*) FROM invoices i WHERE i.client_id = c.id)";
fn map_client(row: &Row) -> rusqlite::Result<Client> {
Ok(Client {
id: Some(row.get(0)?),
@@ -12,17 +20,51 @@ fn map_client(row: &Row) -> rusqlite::Result<Client> {
state_code: row.get(4)?,
po_number: row.get(5)?,
created_at: row.get(6)?,
address_line1: row.get(7)?,
address_line2: row.get(8)?,
city: row.get(9)?,
pincode: row.get(10)?,
gst_category: row.get(11)?,
default_notes: row.get(12)?,
payment_terms_days: row.get(13)?,
invoice_count: row.get(14)?,
})
}
#[tauri::command]
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
/// Composes the stored multi-line address from the structured parts: line 1, line 2, then
/// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts.
pub fn compose_address(line1: &str, line2: &str, city: &str, state_code: &str, pincode: &str) -> String {
let state_name = gst::STATES
.iter()
.find(|(c, _)| *c == state_code)
.map(|(_, n)| *n)
.unwrap_or("");
let place = [city.trim(), state_name]
.iter()
.filter(|p| !p.is_empty())
.cloned()
.collect::<Vec<_>>()
.join(", ");
let pin = pincode.trim();
let last = match (place.is_empty(), pin.is_empty()) {
(false, false) => format!("{place} - {pin}"),
(false, true) => place,
(true, false) => pin.to_string(),
(true, true) => String::new(),
};
[line1.trim(), line2.trim(), last.as_str()]
.iter()
.filter(|l| !l.is_empty())
.cloned()
.collect::<Vec<_>>()
.join("\n")
}
pub fn list_clients_impl(conn: &Connection) -> Result<Vec<Client>, 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",
)
.prepare(&format!(
"SELECT {CLIENT_COLS} FROM clients c ORDER BY c.name COLLATE NOCASE ASC"
))
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], map_client)
@@ -32,29 +74,101 @@ pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, 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())?;
pub fn save_client_impl(conn: &Connection, client: Client) -> Result<Client, String> {
let name = client.name.trim().to_string();
if name.is_empty() {
return Err("Client name is required".into());
}
let gstin = match client.gstin.trim().to_ascii_uppercase().as_str() {
"" | "NA" => String::new(),
g => {
gst::validate_gstin(g).map_err(|e| format!("Client GSTIN: {e}"))?;
g.to_string()
}
};
let state_code = client.state_code.trim().to_string();
if !state_code.is_empty() && !gst::state_exists(&state_code) {
return Err(format!("\"{state_code}\" is not a valid GST state code"));
}
if !gstin.is_empty() && !state_code.is_empty() && gstin[0..2] != state_code {
return Err(format!(
"GSTIN starts with state code {}, but the client's state is {}",
&gstin[0..2],
state_code
));
}
if !CATEGORIES.contains(&client.gst_category.as_str()) {
return Err(format!("Unknown GST category \"{}\"", client.gst_category));
}
let pincode = client.pincode.trim().to_string();
if !pincode.is_empty() && !(pincode.len() == 6 && pincode.bytes().all(|b| b.is_ascii_digit())) {
return Err("Pincode must be 6 digits".into());
}
if let Some(days) = client.payment_terms_days {
if !(0..=365).contains(&days) {
return Err("Payment terms must be between 0 and 365 days".into());
}
}
let line1 = client.address_line1.trim().to_string();
let line2 = client.address_line2.trim().to_string();
let city = client.city.trim().to_string();
// The structured parts win when present; otherwise the incoming (legacy or free-text) address is kept.
let address = if line1.is_empty() && line2.is_empty() && city.is_empty() && pincode.is_empty() {
client.address.clone()
} else {
compose_address(&line1, &line2, &city, &state_code, &pincode)
};
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],
let n = conn
.execute(
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5,
address_line1=?6, address_line2=?7, city=?8, pincode=?9, gst_category=?10,
default_notes=?11, payment_terms_days=?12
WHERE id=?13",
params![
name,
address,
gstin,
state_code,
client.po_number,
line1,
line2,
city,
pincode,
client.gst_category,
client.default_notes,
client.payment_terms_days,
id
],
)
.map_err(|e| e.to_string())?;
if n == 0 {
return Err("Client not found".into());
}
id
}
None => {
conn.execute(
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at,
address_line1, address_line2, city, pincode, gst_category, default_notes,
payment_terms_days)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
client.name,
client.address,
client.gstin,
client.state_code,
name,
address,
gstin,
state_code,
client.po_number,
chrono::Utc::now().to_rfc3339()
chrono::Utc::now().to_rfc3339(),
line1,
line2,
city,
pincode,
client.gst_category,
client.default_notes,
client.payment_terms_days
],
)
.map_err(|e| e.to_string())?;
@@ -62,17 +176,176 @@ pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, Str
}
};
conn.query_row(
"SELECT id, name, address, gstin, state_code, po_number, created_at FROM clients WHERE id = ?1",
&format!("SELECT {CLIENT_COLS} FROM clients c WHERE c.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())?;
/// Deleting is allowed even when invoices reference the client: invoices carry their own
/// name/address/GSTIN snapshot and `invoices.client_id` has no foreign key, so issued invoices are
/// untouched (their id simply no longer resolves). The UI warns with `invoice_count` first.
pub fn delete_client_impl(conn: &Connection, id: i64) -> Result<(), String> {
conn.execute("DELETE FROM clients WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
list_clients_impl(&conn)
}
#[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())?;
save_client_impl(&conn, client)
}
#[tauri::command]
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
delete_client_impl(&conn, id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::open_in_memory;
fn blank(name: &str) -> Client {
serde_json::from_value(serde_json::json!({ "id": null, "name": name })).unwrap()
}
#[test]
fn compose_address_orders_parts() {
assert_eq!(
compose_address("12 MG Road", "Floor 2", "Bengaluru", "29", "560001"),
"12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001"
);
assert_eq!(compose_address("", "", "", "", ""), "");
assert_eq!(compose_address(" A ", "", "", "", "560001"), "A\n560001");
assert_eq!(compose_address("", "", "Pune", "", ""), "Pune");
}
#[test]
fn structured_round_trip_composes_address() {
let conn = open_in_memory().unwrap();
let mut c = blank("Acme");
c.address_line1 = "12 MG Road".into();
c.address_line2 = "Floor 2".into();
c.city = "Bengaluru".into();
c.pincode = "560001".into();
c.state_code = "29".into();
c.gstin = "29abcde1234f1zw".into();
c.gst_category = "registered_regular".into();
c.default_notes = "Net 15".into();
c.payment_terms_days = Some(15);
c.po_number = "PO-9".into();
let saved = save_client_impl(&conn, c).unwrap();
assert!(saved.id.is_some());
assert_eq!(saved.address, "12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
assert_eq!(saved.gstin, "29ABCDE1234F1ZW");
let listed = list_clients_impl(&conn).unwrap();
assert_eq!(listed.len(), 1);
let l = &listed[0];
assert_eq!(l.address_line1, "12 MG Road");
assert_eq!(l.gst_category, "registered_regular");
assert_eq!(l.default_notes, "Net 15");
assert_eq!(l.payment_terms_days, Some(15));
assert_eq!(l.invoice_count, 0);
}
#[test]
fn legacy_address_is_kept_when_no_structured_parts() {
let conn = open_in_memory().unwrap();
let mut c = blank("Legacy");
c.address = "Old style\nfree text".into();
let saved = save_client_impl(&conn, c).unwrap();
assert_eq!(saved.address, "Old style\nfree text");
assert_eq!(saved.gst_category, "unregistered");
// Editing a field other than the address leaves it alone.
let mut edit = saved.clone();
edit.po_number = "PO-1".into();
let again = save_client_impl(&conn, edit).unwrap();
assert_eq!(again.address, "Old style\nfree text");
assert_eq!(again.id, saved.id);
}
#[test]
fn update_changes_the_row_and_unknown_id_fails() {
let conn = open_in_memory().unwrap();
let saved = save_client_impl(&conn, blank("A")).unwrap();
let mut edit = saved.clone();
edit.name = "B".into();
edit.gst_category = "sez".into();
let again = save_client_impl(&conn, edit).unwrap();
assert_eq!((again.name.as_str(), again.gst_category.as_str()), ("B", "sez"));
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
let mut ghost = blank("Ghost");
ghost.id = Some(999);
assert!(save_client_impl(&conn, ghost).is_err());
}
#[test]
fn validation_rejects_bad_input() {
let conn = open_in_memory().unwrap();
assert!(save_client_impl(&conn, blank(" ")).unwrap_err().contains("name"));
let mut c = blank("X");
c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit
assert!(save_client_impl(&conn, c).unwrap_err().contains("GSTIN"));
let mut c = blank("X");
c.gstin = "29ABCDE1234F1ZW".into();
c.state_code = "27".into();
assert!(save_client_impl(&conn, c).unwrap_err().contains("state"));
let mut c = blank("X");
c.gstin = "NA".into();
assert_eq!(save_client_impl(&conn, c).unwrap().gstin, "");
let mut c = blank("X");
c.state_code = "99".into();
assert!(save_client_impl(&conn, c).is_err());
let mut c = blank("X");
c.gst_category = "weird".into();
assert!(save_client_impl(&conn, c).unwrap_err().contains("category"));
let mut c = blank("X");
c.pincode = "56001".into();
assert!(save_client_impl(&conn, c).unwrap_err().contains("Pincode"));
let mut c = blank("X");
c.payment_terms_days = Some(400);
assert!(save_client_impl(&conn, c).is_err());
// Only the valid "NA" client above was stored; every rejected save left no row behind.
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
}
#[test]
fn delete_keeps_referencing_invoices_and_count_is_listed() {
let conn = open_in_memory().unwrap();
let saved = save_client_impl(&conn, blank("Referenced")).unwrap();
let id = saved.id.unwrap();
conn.execute(
"INSERT INTO invoices (number, invoice_date, client_id, client_name, client_address, created_at, updated_at)
VALUES ('T-1', '2026-01-01', ?1, 'Referenced', 'Snap addr', 'now', 'now')",
params![id],
)
.unwrap();
assert_eq!(list_clients_impl(&conn).unwrap()[0].invoice_count, 1);
delete_client_impl(&conn, id).unwrap();
assert!(list_clients_impl(&conn).unwrap().is_empty());
let (name, addr): (String, String) = conn
.query_row("SELECT client_name, client_address FROM invoices WHERE number='T-1'", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr"));
}
}
File diff suppressed because it is too large Load Diff
+144 -10
View File
@@ -3,8 +3,9 @@ use super::series::{validate_series_format, MAX_NUMBER_LEN};
use super::settings::{map_bank, map_settings, SETTINGS_COLS};
use crate::db::format_number;
use crate::gst::{self, TaxType};
use super::payments::{derive_summary, summary_for, today_ist};
use crate::models::{
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, Settings,
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings,
};
use crate::AppState;
use chrono::NaiveDate;
@@ -59,6 +60,8 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
archived_fingerprint: row.get(35)?,
archived_at: row.get(36)?,
items: Vec::new(),
payment: PaymentSummary::default(),
total_paise: 0,
})
}
@@ -90,6 +93,8 @@ fn fetch_invoice(conn: &Connection, id: i64) -> rusqlite::Result<Invoice> {
map_invoice,
)?;
invoice.items = fetch_items(conn, id)?;
invoice.total_paise = gst::rupees_to_paise(invoice.total);
invoice.payment = summary_for(conn, id, &invoice.status, invoice.total, &invoice.due_date)?;
Ok(invoice)
}
@@ -338,15 +343,16 @@ pub fn issue_invoice_impl(
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)",
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at, gst_category)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
input.client_name,
input.client_address,
client_gstin,
stored_pos,
input.po_number,
now
now,
if client_gstin.is_empty() { "unregistered" } else { "registered_regular" }
],
)
.map_err(db)?;
@@ -459,6 +465,12 @@ pub fn issue_invoice(
/// Issued invoices are never deleted: cancelling keeps the row and its number.
pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result<Invoice, String> {
let payments: i64 = conn
.query_row("SELECT COUNT(*) FROM payments WHERE invoice_id = ?1", params![id], |r| r.get(0))
.map_err(|e| e.to_string())?;
if payments > 0 {
return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
}
let now = chrono::Utc::now().to_rfc3339();
let changed = conn
.execute(
@@ -490,23 +502,42 @@ pub fn cancel_invoice(state: State<AppState>, id: i64, reason: String) -> Result
#[tauri::command]
pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
list_invoices_impl(&conn)
}
/// One query: invoices left-joined to their payment sums. Status derivation happens in Rust
/// (`derive_summary`) so list and detail agree.
pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, String> {
let today = today_ist();
let mut stmt = conn
.prepare(
"SELECT id, number, invoice_date, due_date, client_name, total, status, created_at
FROM invoices ORDER BY id DESC",
"SELECT i.id, i.number, i.invoice_date, i.due_date, i.client_name, i.total, i.status,
i.created_at, i.po_number, COALESCE(p.paid, 0), COALESCE(p.tds, 0)
FROM invoices i
LEFT JOIN (SELECT invoice_id, SUM(amount_paise) AS paid, SUM(tds_paise) AS tds
FROM payments GROUP BY invoice_id) p ON p.invoice_id = i.id
ORDER BY i.id DESC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
let total: f64 = row.get(5)?;
let status: String = row.get(6)?;
let due_date: String = row.get(3)?;
let total_paise = gst::rupees_to_paise(total);
let payment = derive_summary(&status, total_paise, row.get(9)?, row.get(10)?, &due_date, &today);
Ok(InvoiceSummary {
id: row.get(0)?,
number: row.get(1)?,
invoice_date: row.get(2)?,
due_date: row.get(3)?,
due_date,
client_name: row.get(4)?,
total: row.get(5)?,
status: row.get(6)?,
total,
status,
created_at: row.get(7)?,
po_number: row.get(8)?,
total_paise,
payment,
})
})
.map_err(|e| e.to_string())?
@@ -515,10 +546,91 @@ pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, Stri
Ok(rows)
}
/// What the History export needs beyond `InvoiceSummary`: the tax split and CA-facing fields, all money as
/// integer paise. Joined to the list rows by `id` on the TypeScript side.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InvoiceLedgerRow {
pub id: i64,
pub client_gstin: String,
pub place_of_supply_code: String,
pub doc_type: String,
/// Distinct non-empty HSN/SAC codes of the lines, in line order.
pub hsn_sac: Vec<String>,
/// Subtotal less discount.
pub taxable_paise: i64,
pub cgst_paise: i64,
pub sgst_paise: i64,
pub igst_paise: i64,
pub reverse_charge: bool,
}
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
{
let mut stmt = conn
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
.map_err(|e| e.to_string())?;
let items = stmt
.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
.map_err(|e| e.to_string())?;
for item in items {
let (id, code) = item.map_err(|e| e.to_string())?;
let list = hsn_by_invoice.entry(id).or_default();
if !code.is_empty() && !list.contains(&code) {
list.push(code);
}
}
}
let mut stmt = conn
.prepare(
"SELECT id, client_gstin, place_of_supply_state_code, doc_type, subtotal, discount,
cgst_amount, sgst_amount, igst_amount, reverse_charge
FROM invoices ORDER BY id DESC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| {
let id: i64 = r.get(0)?;
Ok(InvoiceLedgerRow {
id,
client_gstin: r.get(1)?,
place_of_supply_code: r.get(2)?,
doc_type: r.get(3)?,
hsn_sac: Vec::new(),
taxable_paise: gst::rupees_to_paise(r.get(4)?) - gst::rupees_to_paise(r.get(5)?),
cgst_paise: gst::rupees_to_paise(r.get(6)?),
sgst_paise: gst::rupees_to_paise(r.get(7)?),
igst_paise: gst::rupees_to_paise(r.get(8)?),
reverse_charge: r.get::<_, i64>(9)? != 0,
})
})
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows
.into_iter()
.map(|mut row| {
row.hsn_sac = hsn_by_invoice.remove(&row.id).unwrap_or_default();
row
})
.collect())
}
#[tauri::command]
pub fn list_invoice_ledger(state: State<AppState>) -> Result<Vec<InvoiceLedgerRow>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
list_invoice_ledger_impl(&conn)
}
pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result<Invoice, String> {
fetch_invoice(conn, id).map_err(|e| e.to_string())
}
#[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())
get_invoice_impl(&conn, id)
}
pub fn save_draft_impl(
@@ -661,6 +773,28 @@ mod tests {
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap()
}
#[test]
fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
let mut conn = registered();
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
conn.execute("UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1", params![first.id]).unwrap();
conn.execute(
"INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')",
params![first.id],
)
.unwrap();
let ledger = list_invoice_ledger_impl(&conn).unwrap();
assert_eq!(ledger.len(), 1);
let row = &ledger[0];
assert_eq!(row.id, first.id);
assert_eq!(row.place_of_supply_code, "27");
assert_eq!(row.doc_type, "tax_invoice");
assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
assert_eq!(row.taxable_paise, 731_000);
assert_eq!((row.cgst_paise, row.sgst_paise, row.igst_paise), (65_790, 65_790, 0));
assert!(!row.reverse_charge);
}
#[test]
fn issues_numbered_tax_invoices_with_derived_totals() {
let mut conn = registered();
+4
View File
@@ -1,9 +1,13 @@
pub mod archive;
pub mod assets;
pub mod backup;
pub mod clients;
pub mod files;
pub mod fonts;
pub mod invoice;
pub mod logo;
pub mod payments;
pub mod presets;
pub mod raw;
pub mod series;
pub mod settings;
+454
View File
@@ -0,0 +1,454 @@
//! Payments received against issued invoices.
//!
//! Money is integer paise throughout. Payment status is never stored: it is derived from the
//! payment rows, the invoice total and the due date, so deleting a payment reverts it for free.
//! The issued invoice row is not touched by any of this.
use crate::gst::rupees_to_paise;
use crate::models::PaymentSummary;
use crate::AppState;
use chrono::{FixedOffset, NaiveDate, Utc};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use tauri::State;
const MODES: &[&str] = &["bank_transfer", "upi", "cash", "cheque", "other"];
/// Upper bound on one payment (paise); keeps sums far from i64 overflow.
const MAX_PAISE: i64 = 100_000_000_000;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Payment {
pub id: i64,
pub invoice_id: i64,
pub paid_on: String,
/// Cash received.
pub amount_paise: i64,
/// Tax deducted at source by the client; settles the invoice like cash does.
pub tds_paise: i64,
pub mode: String,
pub reference: String,
pub notes: String,
pub created_at: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaymentInput {
pub invoice_id: i64,
pub paid_on: String,
pub amount_paise: i64,
#[serde(default)]
pub tds_paise: i64,
pub mode: String,
#[serde(default)]
pub reference: String,
#[serde(default)]
pub notes: String,
}
/// Today's date in India (IST, UTC+05:30), as YYYY-MM-DD. Due dates are plain calendar dates.
pub fn today_ist() -> String {
let ist = FixedOffset::east_opt(5 * 3600 + 1800).expect("valid offset");
Utc::now().with_timezone(&ist).date_naive().format("%Y-%m-%d").to_string()
}
/// Derives the payment summary. Only an issued invoice takes payments; anything else is `none`
/// with a zero balance (a cancelled invoice is owed nothing). `settled` is cash plus TDS.
/// Overdue means a due date strictly before `today` while a balance remains; it wins over
/// `partially_paid`, and the paid amounts still show the partial payment.
pub fn derive_summary(
status: &str,
total_paise: i64,
paid_paise: i64,
tds_paise: i64,
due_date: &str,
today: &str,
) -> PaymentSummary {
if status != "issued" {
return PaymentSummary {
paid_paise,
tds_paise,
balance_paise: 0,
payment_status: "none".to_string(),
};
}
let settled = paid_paise + tds_paise;
let balance = (total_paise - settled).max(0);
let overdue = balance > 0
&& NaiveDate::parse_from_str(due_date.trim(), "%Y-%m-%d").is_ok()
&& due_date.trim() < today;
let payment_status = if balance == 0 && total_paise > 0 {
"paid"
} else if overdue {
"overdue"
} else if settled > 0 {
"partially_paid"
} else {
"unpaid"
};
PaymentSummary {
paid_paise,
tds_paise,
balance_paise: balance,
payment_status: payment_status.to_string(),
}
}
/// Summary for one invoice read from the payments table.
pub fn summary_for(
conn: &Connection,
invoice_id: i64,
status: &str,
total: f64,
due_date: &str,
) -> rusqlite::Result<PaymentSummary> {
let (paid, tds): (i64, i64) = conn.query_row(
"SELECT COALESCE(SUM(amount_paise), 0), COALESCE(SUM(tds_paise), 0)
FROM payments WHERE invoice_id = ?1",
params![invoice_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
Ok(derive_summary(status, rupees_to_paise(total), paid, tds, due_date, &today_ist()))
}
fn rupees(paise: i64) -> String {
format!("₹{}.{:02}", paise / 100, paise % 100)
}
fn map_payment(r: &rusqlite::Row) -> rusqlite::Result<Payment> {
Ok(Payment {
id: r.get(0)?,
invoice_id: r.get(1)?,
paid_on: r.get(2)?,
amount_paise: r.get(3)?,
tds_paise: r.get(4)?,
mode: r.get(5)?,
reference: r.get(6)?,
notes: r.get(7)?,
created_at: r.get(8)?,
})
}
const COLS: &str = "id, invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at";
pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result<Payment, String> {
let db = |e: rusqlite::Error| e.to_string();
NaiveDate::parse_from_str(input.paid_on.trim(), "%Y-%m-%d")
.map_err(|_| "Payment date must be a date like 2026-04-01".to_string())?;
if !MODES.contains(&input.mode.as_str()) {
return Err(format!("Unknown payment mode \"{}\"", input.mode));
}
if input.amount_paise < 0 || input.tds_paise < 0 {
return Err("Amounts cannot be negative".into());
}
if input.amount_paise > MAX_PAISE || input.tds_paise > MAX_PAISE {
return Err("That amount is too large".into());
}
if input.amount_paise + input.tds_paise == 0 {
return Err("Enter an amount received or TDS deducted".into());
}
let tx = conn.transaction().map_err(db)?;
let (status, total): (String, f64) = tx
.query_row(
"SELECT status, total FROM invoices WHERE id = ?1",
params![input.invoice_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()
.map_err(db)?
.ok_or_else(|| "Invoice not found".to_string())?;
if status != "issued" {
return Err(format!("Payments can only be recorded on an issued invoice (this one is {status})"));
}
let total_paise = rupees_to_paise(total);
let settled: i64 = tx
.query_row(
"SELECT COALESCE(SUM(amount_paise + tds_paise), 0) FROM payments WHERE invoice_id = ?1",
params![input.invoice_id],
|r| r.get(0),
)
.map_err(db)?;
let balance = total_paise - settled;
let incoming = input.amount_paise + input.tds_paise;
if incoming > balance {
return Err(format!(
"This payment of {} (cash plus TDS) is more than the outstanding balance of {}",
rupees(incoming),
rupees(balance.max(0))
));
}
let now = chrono::Utc::now().to_rfc3339();
tx.execute(
"INSERT INTO payments (invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
input.invoice_id,
input.paid_on.trim(),
input.amount_paise,
input.tds_paise,
input.mode,
input.reference.trim(),
input.notes.trim(),
now
],
)
.map_err(db)?;
let id = tx.last_insert_rowid();
let payment = tx
.query_row(&format!("SELECT {COLS} FROM payments WHERE id = ?1"), params![id], map_payment)
.map_err(db)?;
tx.commit().map_err(db)?;
Ok(payment)
}
pub fn list_payments_impl(conn: &Connection, invoice_id: i64) -> Result<Vec<Payment>, String> {
let mut stmt = conn
.prepare(&format!(
"SELECT {COLS} FROM payments WHERE invoice_id = ?1 ORDER BY paid_on ASC, id ASC"
))
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![invoice_id], map_payment)
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
pub fn delete_payment_impl(conn: &Connection, id: i64) -> Result<(), String> {
let changed = conn
.execute("DELETE FROM payments WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
if changed == 0 {
return Err("Payment not found".into());
}
Ok(())
}
#[tauri::command]
pub fn record_payment(state: State<AppState>, input: PaymentInput) -> Result<Payment, String> {
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
record_payment_impl(&mut conn, input)
}
#[tauri::command]
pub fn list_payments(state: State<AppState>, invoice_id: i64) -> Result<Vec<Payment>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
list_payments_impl(&conn, invoice_id)
}
#[tauri::command]
pub fn delete_payment(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
delete_payment_impl(&conn, id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::invoice::{cancel_invoice_impl, issue_invoice_impl};
use crate::models::InvoiceInput;
use serde_json::json;
use std::path::Path;
/// An unregistered vendor issues without tax, so the invoice total is exactly the line amount.
fn issued(conn: &mut Connection, amount: f64, due: &str) -> i64 {
let input: InvoiceInput = serde_json::from_value(json!({
"invoiceDate": "2026-04-01",
"dueDate": due,
"clientName": "Client Ltd",
"taxType": "none",
"taxRate": 0.0,
"items": [{"description": "Design", "mode": "fixed", "amount": amount}],
}))
.unwrap();
issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({})).unwrap().id
}
fn pay(invoice_id: i64, amount: i64, tds: i64) -> PaymentInput {
PaymentInput {
invoice_id,
paid_on: "2026-04-10".into(),
amount_paise: amount,
tds_paise: tds,
mode: "bank_transfer".into(),
reference: "UTR1".into(),
notes: String::new(),
}
}
fn summary(conn: &Connection, id: i64) -> PaymentSummary {
let (status, total, due): (String, f64, String) = conn
.query_row("SELECT status, total, due_date FROM invoices WHERE id = ?1", params![id], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
})
.unwrap();
summary_for(conn, id, &status, total, &due).unwrap()
}
#[test]
fn partial_then_full_payment_settles() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
assert_eq!(summary(&conn, id).payment_status, "unpaid");
assert_eq!(summary(&conn, id).balance_paise, 100_000);
record_payment_impl(&mut conn, pay(id, 40_000, 0)).unwrap();
let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.paid_paise, s.balance_paise), ("partially_paid", 40_000, 60_000));
record_payment_impl(&mut conn, pay(id, 60_000, 0)).unwrap();
let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("paid", 0));
}
#[test]
fn tds_counts_towards_settling() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
// 10% TDS: the client pays 900.00 in cash and deducts 100.00.
record_payment_impl(&mut conn, pay(id, 90_000, 10_000)).unwrap();
let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.paid_paise, s.tds_paise, s.balance_paise), ("paid", 90_000, 10_000, 0));
// TDS alone is a valid payment too.
let id2 = issued(&mut conn, 500.0, "2999-01-01");
record_payment_impl(&mut conn, pay(id2, 0, 5_000)).unwrap();
assert_eq!(summary(&conn, id2).payment_status, "partially_paid");
}
#[test]
fn overpayment_is_rejected_including_tds() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
let err = record_payment_impl(&mut conn, pay(id, 100_001, 0)).unwrap_err();
assert!(err.contains("more than the outstanding balance of ₹1000.00"), "{err}");
// Cash plus TDS is what counts.
assert!(record_payment_impl(&mut conn, pay(id, 95_000, 6_000)).is_err());
record_payment_impl(&mut conn, pay(id, 50_000, 0)).unwrap();
let err = record_payment_impl(&mut conn, pay(id, 50_001, 0)).unwrap_err();
assert!(err.contains("₹500.00"), "{err}");
let n: i64 = conn.query_row("SELECT COUNT(*) FROM payments", [], |r| r.get(0)).unwrap();
assert_eq!(n, 1);
}
#[test]
fn invalid_input_is_rejected() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
assert!(record_payment_impl(&mut conn, pay(id, 0, 0)).is_err());
assert!(record_payment_impl(&mut conn, pay(id, -1, 0)).is_err());
assert!(record_payment_impl(&mut conn, pay(id, 100, -5)).is_err());
let mut bad = pay(id, 100, 0);
bad.mode = "barter".into();
assert!(record_payment_impl(&mut conn, bad).is_err());
let mut bad = pay(id, 100, 0);
bad.paid_on = "10/04/2026".into();
assert!(record_payment_impl(&mut conn, bad).is_err());
assert!(record_payment_impl(&mut conn, pay(9999, 100, 0)).is_err());
// The table CHECKs hold even if a caller bypasses the command validation.
assert!(conn
.execute(
"INSERT INTO payments (invoice_id, paid_on, amount_paise, tds_paise, mode, created_at)
VALUES (?1, '2026-04-10', 0, 0, 'cash', 'now')",
params![id]
)
.is_err());
}
#[test]
fn draft_and_cancelled_invoices_take_no_payments() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
cancel_invoice_impl(&mut conn, id, "mistake").unwrap();
let err = record_payment_impl(&mut conn, pay(id, 100, 0)).unwrap_err();
assert!(err.contains("issued invoice"), "{err}");
let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("none", 0));
let id2 = issued(&mut conn, 1000.0, "2999-01-01");
conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![id2]).unwrap();
assert!(record_payment_impl(&mut conn, pay(id2, 100, 0)).is_err());
}
#[test]
fn an_invoice_with_payments_cannot_be_cancelled() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
let p = record_payment_impl(&mut conn, pay(id, 100, 0)).unwrap();
let err = cancel_invoice_impl(&mut conn, id, "").unwrap_err();
assert!(err.contains("payments"), "{err}");
delete_payment_impl(&conn, p.id).unwrap();
cancel_invoice_impl(&mut conn, id, "").unwrap();
}
#[test]
fn deleting_a_payment_reverts_the_status() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
let full = record_payment_impl(&mut conn, pay(id, 100_000, 0)).unwrap();
assert_eq!(summary(&conn, id).payment_status, "paid");
delete_payment_impl(&conn, full.id).unwrap();
let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("unpaid", 100_000));
assert!(list_payments_impl(&conn, id).unwrap().is_empty());
assert!(delete_payment_impl(&conn, full.id).is_err());
}
#[test]
fn overdue_is_derived_from_due_date_and_balance() {
let d = |due: &str, paid: i64, tds: i64| derive_summary("issued", 100_000, paid, tds, due, "2026-05-10");
// Due today is not overdue yet; the day after is.
assert_eq!(d("2026-05-10", 0, 0).payment_status, "unpaid");
assert_eq!(d("2026-05-09", 0, 0).payment_status, "overdue");
// Overdue wins over partially paid; the paid amount is still reported.
let s = d("2026-05-09", 10_000, 0);
assert_eq!((s.payment_status.as_str(), s.paid_paise), ("overdue", 10_000));
// A settled invoice is never overdue.
assert_eq!(d("2026-05-09", 90_000, 10_000).payment_status, "paid");
// No usable due date means never overdue.
assert_eq!(d("", 0, 0).payment_status, "unpaid");
assert_eq!(d("not a date", 0, 0).payment_status, "unpaid");
// Cancelled invoices are not owed.
assert_eq!(derive_summary("cancelled", 100_000, 0, 0, "2020-01-01", "2026-05-10").balance_paise, 0);
}
#[test]
fn totals_are_integer_paise_without_float_drift() {
let mut conn = crate::db::open_in_memory().unwrap();
// 0.1 + 0.2 style amounts: 3 x 33.33 = 99.99.
let id = issued(&mut conn, 99.99, "2999-01-01");
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
let s = summary(&conn, id);
assert_eq!((s.paid_paise, s.balance_paise, s.payment_status.as_str()), (9999, 0, "paid"));
let inv = super::super::invoice::get_invoice_impl(&conn, id).unwrap();
assert_eq!(inv.total_paise, 9999);
}
#[test]
fn list_invoices_joins_the_payment_sums() {
let mut conn = crate::db::open_in_memory().unwrap();
let a = issued(&mut conn, 1000.0, "2020-01-01");
let b = issued(&mut conn, 200.0, "2999-01-01");
record_payment_impl(&mut conn, pay(a, 30_000, 5_000)).unwrap();
record_payment_impl(&mut conn, pay(a, 10_000, 0)).unwrap();
record_payment_impl(&mut conn, pay(b, 20_000, 0)).unwrap();
let rows = super::super::invoice::list_invoices_impl(&conn).unwrap();
let get = |id: i64| rows.iter().find(|r| r.id == id).unwrap();
let ra = get(a);
assert_eq!((ra.payment.paid_paise, ra.payment.tds_paise, ra.payment.balance_paise), (40_000, 5_000, 55_000));
assert_eq!(ra.payment.payment_status, "overdue");
assert_eq!(ra.total_paise, 100_000);
assert_eq!(get(b).payment.payment_status, "paid");
// The JSON keeps the existing fields and adds the flattened summary.
let json = serde_json::to_value(ra).unwrap();
assert_eq!(json["balancePaise"], 55_000);
assert_eq!(json["paymentStatus"], "overdue");
assert_eq!(json["total"], 1000.0);
}
}
+177
View File
@@ -0,0 +1,177 @@
use crate::models::ItemPreset;
use crate::AppState;
use rusqlite::{params, Connection, Row};
use tauri::State;
const MODES: &[&str] = &["fixed", "rate"];
const UNITS: &[&str] = &["second", "minute", "hour", "session", "unit"];
/// Upper bound on a preset amount (paise); keeps later integer-paise arithmetic far from overflow.
const MAX_RATE_PAISE: i64 = 100_000_000_000;
const COLS: &str = "id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at";
fn map_preset(row: &Row) -> rusqlite::Result<ItemPreset> {
Ok(ItemPreset {
id: Some(row.get(0)?),
description: row.get(1)?,
hsn_sac: row.get(2)?,
mode: row.get(3)?,
unit: row.get(4)?,
rate_paise: row.get(5)?,
sort_order: row.get(6)?,
created_at: row.get(7)?,
})
}
pub fn list_presets_impl(conn: &Connection) -> Result<Vec<ItemPreset>, String> {
let mut stmt = conn
.prepare(&format!(
"SELECT {COLS} FROM item_presets ORDER BY sort_order ASC, description COLLATE NOCASE ASC, id ASC"
))
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], map_preset)
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPreset, String> {
let description = preset.description.trim().to_string();
if description.is_empty() {
return Err("Preset description is required".into());
}
if !MODES.contains(&preset.mode.as_str()) {
return Err(format!("Unknown line type \"{}\"", preset.mode));
}
if !UNITS.contains(&preset.unit.as_str()) {
return Err(format!("Unknown unit \"{}\"", preset.unit));
}
if !(0..=MAX_RATE_PAISE).contains(&preset.rate_paise) {
return Err("Amount is out of range".into());
}
let hsn = preset.hsn_sac.trim().to_string();
let id = match preset.id {
Some(id) => {
let n = conn
.execute(
"UPDATE item_presets SET description=?1, hsn_sac=?2, mode=?3, unit=?4, rate_paise=?5, sort_order=?6
WHERE id=?7",
params![description, hsn, preset.mode, preset.unit, preset.rate_paise, preset.sort_order, id],
)
.map_err(|e| e.to_string())?;
if n == 0 {
return Err("Preset not found".into());
}
id
}
None => {
conn.execute(
"INSERT INTO item_presets (description, hsn_sac, mode, unit, rate_paise, sort_order, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
description,
hsn,
preset.mode,
preset.unit,
preset.rate_paise,
preset.sort_order,
chrono::Utc::now().to_rfc3339()
],
)
.map_err(|e| e.to_string())?;
conn.last_insert_rowid()
}
};
conn.query_row(
&format!("SELECT {COLS} FROM item_presets WHERE id = ?1"),
params![id],
map_preset,
)
.map_err(|e| e.to_string())
}
pub fn delete_preset_impl(conn: &Connection, id: i64) -> Result<(), String> {
conn.execute("DELETE FROM item_presets WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn list_item_presets(state: State<AppState>) -> Result<Vec<ItemPreset>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
list_presets_impl(&conn)
}
#[tauri::command]
pub fn save_item_preset(state: State<AppState>, preset: ItemPreset) -> Result<ItemPreset, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
save_preset_impl(&conn, preset)
}
#[tauri::command]
pub fn delete_item_preset(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
delete_preset_impl(&conn, id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::open_in_memory;
fn preset(desc: &str) -> ItemPreset {
serde_json::from_value(serde_json::json!({ "id": null, "description": desc })).unwrap()
}
#[test]
fn crud_round_trip_in_integer_paise() {
let conn = open_in_memory().unwrap();
let mut p = preset(" Voice-over session ");
p.mode = "rate".into();
p.unit = "hour".into();
p.rate_paise = 150_050;
p.hsn_sac = " 998397 ".into();
let saved = save_preset_impl(&conn, p).unwrap();
assert_eq!(saved.description, "Voice-over session");
assert_eq!(saved.hsn_sac, "998397");
assert_eq!((saved.mode.as_str(), saved.unit.as_str(), saved.rate_paise), ("rate", "hour", 150_050));
let mut edit = saved.clone();
edit.rate_paise = 200_000;
let again = save_preset_impl(&conn, edit).unwrap();
assert_eq!(again.id, saved.id);
assert_eq!(again.rate_paise, 200_000);
save_preset_impl(&conn, preset("Another")).unwrap();
let listed = list_presets_impl(&conn).unwrap();
assert_eq!(listed.len(), 2);
// Same sort_order: alphabetical.
assert_eq!(listed[0].description, "Another");
delete_preset_impl(&conn, saved.id.unwrap()).unwrap();
assert_eq!(list_presets_impl(&conn).unwrap().len(), 1);
// Deleting a missing id is a no-op.
delete_preset_impl(&conn, 12345).unwrap();
}
#[test]
fn validation_rejects_bad_presets() {
let conn = open_in_memory().unwrap();
assert!(save_preset_impl(&conn, preset(" ")).is_err());
let mut p = preset("X");
p.rate_paise = -1;
assert!(save_preset_impl(&conn, p).is_err());
let mut p = preset("X");
p.mode = "weird".into();
assert!(save_preset_impl(&conn, p).is_err());
let mut p = preset("X");
p.unit = "decade".into();
assert!(save_preset_impl(&conn, p).is_err());
let mut ghost = preset("Ghost");
ghost.id = Some(77);
assert!(save_preset_impl(&conn, ghost).is_err());
assert!(list_presets_impl(&conn).unwrap().is_empty());
}
}
+162 -6
View File
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
const BACKUP_PREFIX: &str = "voiced-pre-v";
/// Highest schema version, i.e. the number of entries in `migrations()`.
const LATEST_VERSION: i64 = 5;
pub(crate) const LATEST_VERSION: i64 = 9;
#[derive(Debug, thiserror::Error)]
pub enum DbError {
@@ -190,8 +190,98 @@ ALTER TABLE app_settings ADD COLUMN logo_meta TEXT NOT NULL DEFAULT '';
ALTER TABLE app_settings ADD COLUMN logo_includes_name TEXT NOT NULL DEFAULT 'auto';
"#;
/// Version 6: user-imported fonts for the three licensed template faces. The files live content-addressed under
/// `<local data dir>/fonts/<sha256>.<ttf|otf>`; invoices freeze the sha256 of the fonts they used in render_prefs.
/// `hidden` retires a font that an issued invoice still references (never deleted while referenced).
const M6: &str = r#"
CREATE TABLE user_fonts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
face TEXT NOT NULL CHECK (face IN ('Now', 'Gotham', 'Open Sauce One')),
family_name TEXT NOT NULL,
full_name TEXT NOT NULL,
style_name TEXT NOT NULL DEFAULT '',
weight INTEGER NOT NULL CHECK (weight BETWEEN 100 AND 900),
style TEXT NOT NULL CHECK (style IN ('normal', 'italic')),
sha256 TEXT NOT NULL,
file_name TEXT NOT NULL,
format TEXT NOT NULL CHECK (format IN ('ttf', 'otf')),
size INTEGER NOT NULL,
fs_type INTEGER NOT NULL DEFAULT 0,
has_rupee INTEGER NOT NULL DEFAULT 0,
licence_ack_at TEXT NOT NULL,
imported_at TEXT NOT NULL,
hidden INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_user_fonts_sha ON user_fonts(sha256);
-- One visible font per face, weight and style; a hidden one keeps its row but frees the slot.
CREATE UNIQUE INDEX idx_user_fonts_slot ON user_fonts(face, weight, style) WHERE hidden = 0;
"#;
/// Version 7: structured client addresses, GST category and per-client defaults, plus saved item presets.
/// `clients.address` stays the composed multi-line string (issued invoices and the PDF read it); the new
/// parts are an editing aid. The address state is `clients.state_code`, which is also the default place
/// of supply, so there is no second state column. `payment_terms_days` NULL means "use the settings default".
/// `item_presets.rate_paise` is the fixed amount for mode 'fixed' and the per-unit rate for mode 'rate'.
const M7: &str = r#"
ALTER TABLE clients ADD COLUMN address_line1 TEXT NOT NULL DEFAULT '';
ALTER TABLE clients ADD COLUMN address_line2 TEXT NOT NULL DEFAULT '';
ALTER TABLE clients ADD COLUMN city TEXT NOT NULL DEFAULT '';
ALTER TABLE clients ADD COLUMN pincode TEXT NOT NULL DEFAULT '';
ALTER TABLE clients ADD COLUMN gst_category TEXT NOT NULL DEFAULT 'unregistered'
CHECK (gst_category IN ('registered_regular', 'unregistered', 'composition', 'sez', 'overseas'));
ALTER TABLE clients ADD COLUMN default_notes TEXT NOT NULL DEFAULT '';
ALTER TABLE clients ADD COLUMN payment_terms_days INTEGER;
UPDATE clients SET gst_category = 'registered_regular' WHERE gstin NOT IN ('', 'NA');
CREATE TABLE item_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
description TEXT NOT NULL,
hsn_sac TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'fixed' CHECK (mode IN ('fixed', 'rate')),
unit TEXT NOT NULL DEFAULT 'unit' CHECK (unit IN ('second', 'minute', 'hour', 'session', 'unit')),
rate_paise INTEGER NOT NULL DEFAULT 0 CHECK (rate_paise >= 0),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
"#;
/// Version 8: payments received against issued invoices. Amounts are integer paise. `amount_paise` is the cash
/// received and `tds_paise` the tax the client deducted at source; both count towards settling the invoice.
/// Payment status is derived from these rows and the due date, never stored, and the issued invoice row is untouched.
const M8: &str = r#"
CREATE TABLE payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id INTEGER NOT NULL REFERENCES invoices(id),
paid_on TEXT NOT NULL,
amount_paise INTEGER NOT NULL DEFAULT 0 CHECK (amount_paise >= 0),
tds_paise INTEGER NOT NULL DEFAULT 0 CHECK (tds_paise >= 0),
mode TEXT NOT NULL CHECK (mode IN ('bank_transfer', 'upi', 'cash', 'cheque', 'other')),
reference TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
CHECK (amount_paise + tds_paise > 0)
);
CREATE INDEX idx_payments_invoice ON payments(invoice_id);
"#;
/// Version 9: `auto_backup` switches the daily automatic backup (see commands::backup). On by default. It is read and
/// written through its own commands, not through `Settings`, so a settings save can never clobber it.
const M9: &str = r#"
ALTER TABLE app_settings ADD COLUMN auto_backup INTEGER NOT NULL DEFAULT 1;
"#;
fn migrations() -> Migrations<'static> {
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5)])
Migrations::new(vec![
M::up(SCHEMA),
M::up(M2),
M::up(M3),
M::up(M4),
M::up(M5),
M::up(M6),
M::up(M7),
M::up(M8),
M::up(M9),
])
}
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
@@ -211,7 +301,14 @@ pub fn open(path: &Path, backup_dir: &Path) -> Result<Connection, DbError> {
Ok(conn)
}
fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
/// Write a consistent, compacted snapshot of the open database to `target` (which must not exist yet).
/// Safe while other connections read and write: SQLite takes a read transaction for the copy.
pub(crate) fn vacuum_into(conn: &Connection, target: &Path) -> rusqlite::Result<()> {
let quoted = target.to_string_lossy().replace('\'', "''");
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))
}
pub(crate) fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
conn.query_row(
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
[],
@@ -229,8 +326,7 @@ fn backup_before_migration(
fs::create_dir_all(backup_dir)?;
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
let quoted = target.to_string_lossy().replace('\'', "''");
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))?;
vacuum_into(conn, &target)?;
// A failed prune must not block startup; the backup itself already succeeded.
let _ = prune_backups(backup_dir, MAX_BACKUPS);
Ok(target)
@@ -620,7 +716,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
assert_eq!(user_version(&old), 4);
}
let conn = open(&path, &dir.path().join("backups")).unwrap();
assert_eq!(user_version(&conn), 5);
assert_eq!(user_version(&conn), LATEST_VERSION);
let (logo, meta): (String, String) = conn
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| {
Ok((r.get(0)?, r.get(1)?))
@@ -630,6 +726,66 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
assert_eq!(backups_in(&dir.path().join("backups")).len(), 1);
}
#[test]
fn m7_fresh_database_has_client_and_preset_columns() {
let conn = open_in_memory().unwrap();
conn.prepare(
"SELECT address_line1, address_line2, city, pincode, gst_category, default_notes, payment_terms_days FROM clients",
)
.unwrap();
conn.prepare("SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at FROM item_presets")
.unwrap();
conn.execute(
"INSERT INTO clients (name, created_at) VALUES ('X', 'now')",
[],
)
.unwrap();
let (cat, terms): (String, Option<i64>) = conn
.query_row("SELECT gst_category, payment_terms_days FROM clients", [], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap();
assert_eq!((cat.as_str(), terms), ("unregistered", None));
// The category CHECK rejects unknown values.
assert!(conn
.execute("UPDATE clients SET gst_category = 'bogus'", [])
.is_err());
}
#[test]
fn m7_upgrade_from_v6_keeps_client_data_and_backfills_category() {
let mut conn = Connection::open_in_memory().unwrap();
let all = vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)];
Migrations::new(all).to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 6);
conn.execute(
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
VALUES ('Acme', 'Line one\nLine two', '29ABCDE1234F1Z5', '29', 'PO-1', 'now'),
('Plain', '', '', '', '', 'now'),
('Na', '', 'NA', '', '', 'now')",
[],
)
.unwrap();
migrations().to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), LATEST_VERSION);
type Row = (String, String, String, String, String, String, String);
let rows: Vec<Row> = conn
.prepare("SELECT name, address, gstin, state_code, po_number, gst_category, address_line1 FROM clients ORDER BY id")
.unwrap()
.query_map([], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?))
})
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(rows[0].1, "Line one\nLine two");
assert_eq!((rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()), ("29ABCDE1234F1Z5", "29", "PO-1"));
assert_eq!(rows[0].5, "registered_regular");
assert_eq!(rows[0].6, "");
assert_eq!(rows[1].5, "unregistered");
assert_eq!(rows[2].5, "unregistered");
}
fn build_v1_database(path: &Path) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(V1_SCHEMA).unwrap();
+31
View File
@@ -56,6 +56,15 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir));
return Err(e.into());
}
// A restore staged by the Data tab is swapped in now, before anything opens the database.
let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
commands::backup::ApplyOutcome::NothingPending => {}
commands::backup::ApplyOutcome::Applied { safety_dir } => {
eprintln!("Backup restored; the previous data is in {}", safety_dir.display());
}
commands::backup::ApplyOutcome::Failed { message } => eprintln!("Backup restore failed: {message}"),
}
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
Ok(conn) => conn,
Err(e) => {
@@ -71,6 +80,7 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
if let Err(e) = commands::logo::ensure_logo_derived_impl(&conn, &data_dir) {
eprintln!("Could not derive the logo images: {e}");
}
commands::backup::spawn_auto_backup(dirs);
Ok(AppState {
db: Mutex::new(conn),
data_dir,
@@ -80,6 +90,9 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// After "Restart" in the Data tab the new process waits for the old one (and its single-instance lock) to go.
commands::backup::wait_if_relaunched();
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
// combinations. Respect an explicit user setting, otherwise turn it off.
#[cfg(target_os = "linux")]
@@ -121,6 +134,9 @@ pub fn run() {
commands::clients::list_clients,
commands::clients::save_client,
commands::clients::delete_client,
commands::presets::list_item_presets,
commands::presets::save_item_preset,
commands::presets::delete_item_preset,
commands::invoice::peek_next_invoice_number,
commands::invoice::issue_invoice,
commands::invoice::cancel_invoice,
@@ -130,6 +146,16 @@ pub fn run() {
commands::invoice::list_drafts,
commands::invoice::get_draft,
commands::invoice::delete_draft,
commands::payments::record_payment,
commands::payments::list_payments,
commands::payments::delete_payment,
commands::backup::create_backup,
commands::backup::restore_backup,
commands::backup::cancel_pending_restore,
commands::backup::get_backup_status,
commands::backup::set_auto_backup,
commands::backup::restart_app,
commands::invoice::list_invoice_ledger,
commands::assets::import_asset,
commands::assets::save_asset_bytes,
commands::assets::read_asset_data_uri,
@@ -141,6 +167,11 @@ pub fn run() {
commands::archive::archive_pdf,
commands::archive::read_archive,
commands::archive::archive_status,
commands::fonts::inspect_font,
commands::fonts::import_font,
commands::fonts::list_user_fonts,
commands::fonts::get_user_font_bytes,
commands::fonts::remove_user_font,
commands::files::write_export_file,
commands::files::get_last_export_dir,
commands::files::reveal_in_folder,
+76
View File
@@ -95,6 +95,50 @@ pub struct Client {
pub po_number: String,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub address_line1: String,
#[serde(default)]
pub address_line2: String,
#[serde(default)]
pub city: String,
#[serde(default)]
pub pincode: String,
/// registered_regular | unregistered | composition | sez | overseas. Informational only.
#[serde(default = "default_gst_category")]
pub gst_category: String,
/// Notes / payment terms text copied onto a new invoice when this client is picked.
#[serde(default)]
pub default_notes: String,
/// None = use the settings default.
#[serde(default)]
pub payment_terms_days: Option<i64>,
/// Number of invoices that reference this client; read-only, filled by list_clients.
#[serde(default)]
pub invoice_count: i64,
}
fn default_gst_category() -> String {
"unregistered".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ItemPreset {
pub id: Option<i64>,
pub description: String,
#[serde(default)]
pub hsn_sac: String,
/// "fixed" = rate_paise is the line amount; "rate" = rate_paise is the rate per unit.
#[serde(default = "default_mode")]
pub mode: String,
#[serde(default = "default_unit")]
pub unit: String,
#[serde(default)]
pub rate_paise: i64,
#[serde(default)]
pub sort_order: i64,
#[serde(default)]
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -221,6 +265,34 @@ pub struct Invoice {
pub archived_fingerprint: Option<String>,
pub archived_at: Option<String>,
pub items: Vec<InvoiceItem>,
/// Derived from the payments table (never stored on the invoice): see commands::payments.
#[serde(flatten)]
pub payment: PaymentSummary,
/// `total` in integer paise.
#[serde(default)]
pub total_paise: i64,
}
/// What has been received against an invoice. All amounts are integer paise; `payment_status` is
/// unpaid | partially_paid | paid | overdue, or `none` for an invoice that takes no payments.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaymentSummary {
pub paid_paise: i64,
pub tds_paise: i64,
pub balance_paise: i64,
pub payment_status: String,
}
impl Default for PaymentSummary {
fn default() -> Self {
PaymentSummary {
paid_paise: 0,
tds_paise: 0,
balance_paise: 0,
payment_status: "none".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -234,6 +306,10 @@ pub struct InvoiceSummary {
pub total: f64,
pub status: String,
pub created_at: String,
pub po_number: String,
pub total_paise: i64,
#[serde(flatten)]
pub payment: PaymentSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+40 -5
View File
@@ -6,14 +6,28 @@ import { AppShell, type AppView } from "./components/AppShell";
import Onboarding from "./views/Onboarding";
import NewInvoice from "./views/NewInvoice";
import InvoiceHistory from "./views/InvoiceHistory";
import SeriesSettings from "./views/SeriesSettings";
import InvoiceDetail from "./views/InvoiceDetail";
import Clients from "./views/Clients";
import { ToastProvider, useToast } from "./components/ToastProvider";
import AppSettings from "./views/AppSettings";
export default function App() {
return (
<ToastProvider>
<AppInner />
</ToastProvider>
);
}
function AppInner() {
const toast = useToast();
const [settings, setSettings] = useState<Settings | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<AppView>("new");
const [detailId, setDetailId] = useState<number | null>(null);
// A draft made by "Duplicate" that the New invoice form should open once it is shown.
const [openDraftId, setOpenDraftId] = useState<number | null>(null);
const refreshSettings = useCallback(async () => {
try {
@@ -81,7 +95,7 @@ export default function App() {
try {
await api.saveSettings(next);
} catch (e) {
setError(String(e));
toast.error("Could not save the theme", String(e));
}
};
@@ -94,12 +108,33 @@ export default function App() {
onSettingsChange={refreshSettings}
active={view === "new"}
onActivate={() => setView("new")}
openDraftId={openDraftId}
onDraftOpened={() => setOpenDraftId(null)}
/>
<div hidden={view !== "invoices"}>
<InvoiceHistory settings={settings} active={view === "invoices"} />
<InvoiceHistory
settings={settings}
active={view === "invoices"}
onOpen={(id) => {
setDetailId(id);
setView("invoice");
}}
/>
</div>
<div hidden={view !== "series"}>
<SeriesSettings active={view === "series"} />
{view === "invoice" && detailId !== null ? (
<InvoiceDetail
key={detailId}
invoiceId={detailId}
settings={settings}
onBack={() => setView("invoices")}
onDuplicated={(draftId) => {
setOpenDraftId(draftId);
setView("new");
}}
/>
) : null}
<div hidden={view !== "clients"}>
<Clients active={view === "clients"} />
</div>
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
</AppShell>
+5 -4
View File
@@ -11,12 +11,13 @@ import {
import { Asleep, Light } from "@carbon/icons-react";
import type { ReactNode } from "react";
export type AppView = "new" | "invoices" | "series" | "settings";
/** "invoice" is the detail page of one invoice; it is reached from "invoices" and highlights that tab. */
export type AppView = "new" | "invoices" | "invoice" | "clients" | "settings";
const NAV: { id: AppView; label: string }[] = [
const NAV: { id: Exclude<AppView, "invoice">; label: string }[] = [
{ id: "new", label: "New invoice" },
{ id: "invoices", label: "Invoices" },
{ id: "series", label: "Series" },
{ id: "clients", label: "Clients" },
{ id: "settings", label: "Settings" },
];
@@ -49,7 +50,7 @@ export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: A
{NAV.map((item) => (
<HeaderMenuItem
key={item.id}
isCurrentPage={view === item.id}
isCurrentPage={view === item.id || (view === "invoice" && item.id === "invoices")}
onClick={(event) => {
event.preventDefault();
onNavigate(item.id);
+224
View File
@@ -0,0 +1,224 @@
import { useEffect, useState, type RefObject } from "react";
import { Column, Grid, InlineNotification, Modal, Select, SelectItem, TextArea, TextInput } from "@carbon/react";
import { api } from "../lib/api";
import {
GST_CATEGORY_LABELS,
INDIAN_STATES,
blankClient,
type Client,
type GstCategory,
} from "../lib/types";
import { composeAddress, pincodeError, splitLegacyAddress } from "../lib/clientAddress";
import { clientGstinStateError } from "../lib/validators";
import { useToast } from "./ToastProvider";
interface ClientModalProps {
open: boolean;
/** The client to edit; null opens a blank one. */
client: Client | null;
onClose: () => void;
onSaved: (client: Client) => void;
/** Focus returns here when the modal closes. */
launcherRef?: RefObject<HTMLElement | null>;
}
/** Fills the structured address from the legacy free-text one when a client predates the new fields. */
export function prepareForEdit(client: Client): { form: Client; splitFromLegacy: boolean } {
const structured = client.addressLine1 || client.addressLine2 || client.city || client.pincode;
if (structured || !client.address.trim()) return { form: client, splitFromLegacy: false };
return { form: { ...client, ...splitLegacyAddress(client.address) }, splitFromLegacy: true };
}
export default function ClientModal({ open, client, onClose, onSaved, launcherRef }: ClientModalProps) {
const toast = useToast();
const [form, setForm] = useState<Client>(blankClient());
const [split, setSplit] = useState(false);
const [busy, setBusy] = useState(false);
const [touched, setTouched] = useState(false);
useEffect(() => {
if (!open) return;
const prepared = prepareForEdit(client ?? blankClient());
setForm(prepared.form);
setSplit(prepared.splitFromLegacy);
setBusy(false);
setTouched(false);
}, [open, client]);
const set = <K extends keyof Client>(key: K, value: Client[K]) => setForm((prev) => ({ ...prev, [key]: value }));
const nameProblem = form.name.trim() ? undefined : "Client name is required";
const gstinProblem = clientGstinStateError(form.gstin, form.stateCode);
const pinProblem = pincodeError(form.pincode);
const termsProblem =
form.paymentTermsDays !== null && (form.paymentTermsDays < 0 || form.paymentTermsDays > 365)
? "Between 0 and 365 days"
: undefined;
const blocking = nameProblem ?? gstinProblem ?? pinProblem ?? termsProblem;
const submit = async () => {
setTouched(true);
if (blocking) return;
setBusy(true);
const structured = form.addressLine1 || form.addressLine2 || form.city || form.pincode;
const toSave: Client = {
...form,
name: form.name.trim(),
gstin: form.gstin.trim().toUpperCase(),
address: structured
? composeAddress({
line1: form.addressLine1,
line2: form.addressLine2,
city: form.city,
stateCode: form.stateCode,
pincode: form.pincode,
})
: form.address,
};
try {
const saved = await api.saveClient(toSave);
toast.success(toSave.id === null ? "Client added" : "Client updated", saved.name);
onSaved(saved);
} catch (e) {
toast.error("Could not save client", String(e));
setBusy(false);
}
};
return (
<Modal
open={open}
modalHeading={client?.id ? "Edit client" : "New client"}
primaryButtonText={busy ? "Saving…" : "Save client"}
secondaryButtonText="Cancel"
primaryButtonDisabled={busy}
onRequestClose={onClose}
onRequestSubmit={() => void submit()}
launcherButtonRef={launcherRef}
size="md"
>
<Grid narrow>
{split ? (
<Column sm={4} md={8} lg={16}>
<InlineNotification
kind="info"
lowContrast
hideCloseButton
title="Address split automatically"
subtitle="This client has a free-text address. Check the lines below before saving."
/>
</Column>
) : null}
<Column sm={4} md={8} lg={16}>
<TextInput
id="cm-name"
labelText="Client name"
value={form.name}
onChange={(e) => set("name", e.target.value)}
invalid={touched && Boolean(nameProblem)}
invalidText={nameProblem}
data-modal-primary-focus
/>
</Column>
<Column sm={4} md={4} lg={8}>
<Select
id="cm-category"
labelText="GST category"
value={form.gstCategory}
onChange={(e) => set("gstCategory", e.target.value as GstCategory)}
>
{Object.entries(GST_CATEGORY_LABELS).map(([value, text]) => (
<SelectItem key={value} value={value} text={text} />
))}
</Select>
</Column>
<Column sm={4} md={4} lg={8}>
<TextInput
id="cm-gstin"
labelText="GSTIN (optional)"
value={form.gstin}
onChange={(e) => set("gstin", e.target.value.toUpperCase())}
invalid={Boolean(form.gstin.trim()) && Boolean(gstinProblem)}
invalidText={gstinProblem}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<TextInput
id="cm-line1"
labelText="Address line 1"
value={form.addressLine1}
onChange={(e) => set("addressLine1", e.target.value)}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<TextInput
id="cm-line2"
labelText="Address line 2"
value={form.addressLine2}
onChange={(e) => set("addressLine2", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput id="cm-city" labelText="City" value={form.city} onChange={(e) => set("city", e.target.value)} />
</Column>
<Column sm={4} md={4} lg={7}>
<Select
id="cm-state"
labelText="State (also the default place of supply)"
value={form.stateCode}
onChange={(e) => set("stateCode", e.target.value)}
>
<SelectItem value="" text="Not set" />
{INDIAN_STATES.map((s) => (
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
))}
</Select>
</Column>
<Column sm={4} md={4} lg={4}>
<TextInput
id="cm-pin"
labelText="Pincode"
inputMode="numeric"
maxLength={6}
value={form.pincode}
onChange={(e) => set("pincode", e.target.value)}
invalid={Boolean(pinProblem)}
invalidText={pinProblem}
/>
</Column>
<Column sm={4} md={4} lg={8}>
<TextInput
id="cm-po"
labelText="Default PO number (optional)"
value={form.poNumber}
onChange={(e) => set("poNumber", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={8}>
<TextInput
id="cm-terms"
labelText="Payment terms (days, blank = default)"
inputMode="numeric"
value={form.paymentTermsDays === null ? "" : String(form.paymentTermsDays)}
onChange={(e) => {
const raw = e.target.value.trim();
set("paymentTermsDays", raw === "" ? null : Number.isFinite(Number(raw)) ? Math.trunc(Number(raw)) : null);
}}
invalid={Boolean(termsProblem)}
invalidText={termsProblem}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<TextArea
id="cm-notes"
labelText="Default notes / payment terms text"
helperText="Copied into the invoice notes when this client is picked and the notes are empty."
rows={3}
value={form.defaultNotes}
onChange={(e) => set("defaultNotes", e.target.value)}
/>
</Column>
</Grid>
</Modal>
);
}
+278
View File
@@ -0,0 +1,278 @@
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 { api } from "../lib/api";
import {
backupFileName,
ensureZipExtension,
formatBytes,
formatWhen,
type BackupStatus,
type PendingRestore,
} from "../lib/backup";
import { useReturnFocus } from "../hooks/useReturnFocus";
import { useToast } from "./ToastProvider";
const ZIP_FILTER = [{ name: "Voiced backup", extensions: ["zip"] }];
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() {
const toast = useToast();
const [status, setStatus] = useState<BackupStatus | null>(null);
const [backingUp, setBackingUp] = useState(false);
const [restorePath, setRestorePath] = useState<string | null>(null);
const [step, setStep] = useState<RestoreStep>("confirm");
const [staged, setStaged] = useState<PendingRestore | null>(null);
const [restoreError, setRestoreError] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);
useReturnFocus(restorePath !== null);
const refresh = useCallback(async () => {
try {
setStatus(await api.getBackupStatus());
} catch (e) {
toast.error("Could not load backup status", String(e));
}
}, [toast]);
useEffect(() => {
void refresh();
}, [refresh]);
const backUpNow = async () => {
setBackingUp(true);
try {
let dir = "";
try {
dir = await api.getLastExportDir();
} catch {
// No remembered folder: the dialog opens wherever the system prefers.
}
const chosen = await save({ defaultPath: joinPath(dir, backupFileName()), filters: ZIP_FILTER });
if (!chosen) return;
const summary = await api.createBackup(ensureZipExtension(chosen));
toast.success("Backup saved", `${summary.fileCount} files, ${formatBytes(summary.totalBytes)}: ${summary.path}`);
} catch (e) {
toast.error("Backup failed", String(e));
} finally {
setBackingUp(false);
}
};
const chooseRestoreFile = async () => {
try {
const picked = await open({ multiple: false, directory: false, filters: ZIP_FILTER });
if (typeof picked !== "string") return;
setRestoreError(null);
setStaged(null);
setStep("confirm");
setRestorePath(picked);
} catch (e) {
toast.error("Could not open the file picker", String(e));
}
};
const closeRestore = () => {
if (step === "staging") return;
setRestorePath(null);
void refresh();
};
const stageRestore = async () => {
if (!restorePath) return;
setStep("staging");
setRestoreError(null);
try {
setStaged(await api.restoreBackup(restorePath));
setStep("ready");
} catch (e) {
setRestoreError(String(e));
setStep("confirm");
toast.error("This backup cannot be restored", String(e));
}
};
const restart = async () => {
setRestarting(true);
try {
await api.restartApp();
} catch (e) {
setRestarting(false);
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the restore.`);
}
};
const cancelPending = async () => {
try {
await api.cancelPendingRestore();
toast.info("Restore cancelled", "Your data was not changed.");
} catch (e) {
toast.error("Could not cancel the restore", String(e));
}
setRestorePath(null);
await refresh();
};
const toggleAuto = async (enabled: boolean) => {
try {
const saved = await api.setAutoBackup(enabled);
setStatus((prev) => (prev ? { ...prev, autoBackup: saved } : prev));
toast.success(saved ? "Automatic backup on" : "Automatic backup off");
} catch (e) {
toast.error("Could not change automatic backup", String(e));
await refresh();
}
};
const openFolder = async (path: string) => {
try {
await api.openFile(path);
} catch (e) {
toast.error("Could not open the folder", String(e));
}
};
const pending = status?.pendingRestore ?? null;
return (
<section className="voiced-stack">
{pending ? (
<InlineNotification
kind="warning"
lowContrast
hideCloseButton
title="A restore is waiting for a restart"
subtitle={`${pending.backupName} (${pending.fileCount} files, ${formatBytes(pending.totalBytes)}) has been checked. Restart Voiced to switch to it; your current data is kept in a safety copy.`}
/>
) : null}
{pending ? (
<div className="voiced-actions">
<Button size="md" onClick={() => void restart()} disabled={restarting}>
{restarting ? "Restarting…" : "Restart now"}
</Button>
<Button size="md" kind="tertiary" onClick={() => void cancelPending()} disabled={restarting}>
Cancel restore
</Button>
</div>
) : null}
<Tile>
<h2 className="voiced-section-title">Back up</h2>
<p className="voiced-muted">
Saves one zip with your invoices, clients, payments, settings, logo and signature images, archived PDFs and imported
fonts. Keep a copy somewhere other than this computer.
</p>
<div className="voiced-actions">
<Button size="md" onClick={() => void backUpNow()} disabled={backingUp}>
{backingUp ? "Backing up…" : "Back up now"}
</Button>
{backingUp ? <InlineLoading description="Writing the backup" /> : null}
</div>
</Tile>
<Tile>
<h2 className="voiced-section-title">Restore from backup</h2>
<p className="voiced-muted">
Replaces everything in Voiced with the contents of a backup file. Your current data is moved into a safety copy first,
never deleted.
</p>
<div className="voiced-actions">
<Button size="md" kind="danger--tertiary" onClick={() => void chooseRestoreFile()} disabled={restorePath !== null || !!pending}>
Restore from backup…
</Button>
</div>
{status?.lastRestore ? (
<p className="voiced-muted" role="status">
{status.lastRestore.ok
? `Last restore: ${status.lastRestore.backupName || "a backup"}, ${formatWhen(status.lastRestore.at)}.`
: `The last restore attempt (${formatWhen(status.lastRestore.at)}) did not go through: ${status.lastRestore.message}`}{" "}
{status.lastRestore.safetyDir ? (
<Button kind="ghost" size="sm" onClick={() => void api.revealInFolder(status.lastRestore!.safetyDir!).catch((e) => toast.error("Could not open the folder", String(e)))}>
Show safety copy
</Button>
) : null}
</p>
) : null}
</Tile>
<Tile>
<h2 className="voiced-section-title">Automatic backup</h2>
<Toggle
id="autoBackup"
labelText="Back up automatically every day"
labelA="Off"
labelB="On"
toggled={status?.autoBackup ?? true}
disabled={!status}
onToggle={(checked: boolean) => void toggleAuto(checked)}
/>
<p className="voiced-muted">
While Voiced is open it writes one backup a day into its data folder and keeps the newest 14. This changes right away; you
do not need to click Save settings.
</p>
<p role="status">
{status === null
? "Loading…"
: status.lastAutoBackup
? `Last automatic backup: ${formatWhen(status.lastAutoBackup)} (${status.autoBackupCount} kept).`
: "No automatic backup yet."}
</p>
<div className="voiced-actions">
<Button size="md" kind="tertiary" disabled={!status} onClick={() => status && void openFolder(status.autoDir)}>
Open backup folder
</Button>
</div>
</Tile>
<Modal
open={restorePath !== null}
danger={step === "confirm"}
modalHeading={step === "ready" ? "Restart to finish restoring" : "Restore from this backup?"}
primaryButtonText={
step === "confirm" ? "Check and restore" : step === "staging" ? "Checking the backup…" : restarting ? "Restarting…" : "Restart now"
}
secondaryButtonText={step === "ready" ? "Later" : "Cancel"}
primaryButtonDisabled={step === "staging" || restarting}
onRequestClose={closeRestore}
onRequestSubmit={() => void (step === "confirm" ? stageRestore() : step === "ready" ? restart() : undefined)}
onSecondarySubmit={closeRestore}
>
<div className="voiced-stack">
{restoreError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Cannot restore" subtitle={restoreError} /> : null}
{step === "ready" && staged ? (
<>
<p>
<strong>{staged.backupName}</strong> passed every check: {staged.fileCount} files, {formatBytes(staged.totalBytes)}, made{" "}
{formatWhen(staged.backupCreatedAt)}.
</p>
<p>
Voiced must restart to switch to it. Until then nothing has changed. Choose Later to keep working and restart from this tab
when you are ready (anything you add before restarting will not be in the restored data, but stays in the safety copy).
</p>
</>
) : (
<>
<p>
File: <strong>{restorePath}</strong>
</p>
<p>
Everything now in Voiced (invoices, clients, payments, settings, images, archived PDFs and fonts) will be replaced by the
contents of this backup.
</p>
<ul className="voiced-list">
<li>The file is checked first; a damaged or newer-version backup is refused and nothing changes.</li>
<li>Your current data is moved to a safety copy in Voiced&rsquo;s backups folder before the swap. It is never deleted.</li>
<li>Voiced restarts to complete the restore.</li>
</ul>
</>
)}
</div>
</Modal>
</section>
);
}
+282
View File
@@ -0,0 +1,282 @@
import { useCallback, useEffect, useState } from "react";
import {
Button,
Checkbox,
FileUploaderButton,
InlineLoading,
InlineNotification,
Select,
SelectItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tag,
Tile,
} from "@carbon/react";
import { TrashCan } from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import {
clientRefusal,
FACE_SUBSTITUTES,
notifyUserFontsChanged,
USER_FACES,
weightName,
type FontInspection,
type UserFace,
type UserFontRow,
} from "../lib/fontImport";
import { TEMPLATE_CATALOG } from "../pdf/templates/catalog";
const MAX_FONT_BYTES = 16 * 1024 * 1024;
const WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
interface Pending {
face: UserFace;
fileName: string;
bytes: Uint8Array;
inspection: FontInspection;
weight: number;
italic: boolean;
acknowledged: boolean;
}
const templatesUsing = (face: UserFace): string =>
TEMPLATE_CATALOG.filter((t) => t.faces?.includes(face))
.map((t) => t.name)
.join(", ");
/**
* Settings, Fonts: one row for each of the three licensed template faces (Now, Gotham, Open Sauce One), showing the
* bundled look-alike in use and any imported files. Choosing a file shows what was found in it; the licence
* acknowledgement is required before it is stored.
*/
export function FontOverrideTable() {
const [fonts, setFonts] = useState<UserFontRow[]>([]);
const [loading, setLoading] = useState(true);
const [pending, setPending] = useState<Pending | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const reload = useCallback(async () => {
try {
setFonts(await api.listUserFonts());
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
const choose = async (face: UserFace, file: File | undefined) => {
if (!file) return;
setError(null);
setNotice(null);
setPending(null);
if (file.size > MAX_FONT_BYTES) return setError("The font file is larger than the 16 MB limit.");
setBusy(face);
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const refusal = clientRefusal(bytes);
if (refusal) return setError(refusal);
const inspection = await api.inspectFont(bytes);
setPending({ face, fileName: file.name, bytes, inspection, weight: inspection.weight, italic: inspection.italic, acknowledged: false });
} catch (e) {
setError(String(e));
} finally {
setBusy(null);
}
};
const doImport = async () => {
if (!pending || !pending.acknowledged) return;
setBusy(pending.face);
setError(null);
try {
await api.importFont(pending.bytes, {
face: pending.face,
fileName: pending.fileName,
weight: pending.weight,
italic: pending.italic,
licenceAcknowledged: true,
});
setPending(null);
await reload();
notifyUserFontsChanged();
} catch (e) {
setError(String(e));
} finally {
setBusy(null);
}
};
const remove = async (font: UserFontRow) => {
const ok = await confirm(`Remove ${font.fileName} from ${font.face}? Templates go back to ${FACE_SUBSTITUTES[font.face]} for that weight.`, {
title: "Remove font",
kind: "warning",
});
if (!ok) return;
setBusy(font.face);
setError(null);
try {
const outcome = await api.removeUserFont(font.id);
setNotice(
outcome.hidden
? `${font.fileName} is used by an issued invoice, so it was kept for that invoice and will not be used for new ones.`
: `${font.fileName} was removed.`,
);
await reload();
notifyUserFontsChanged();
} catch (e) {
setError(String(e));
} finally {
setBusy(null);
}
};
return (
<div className="voiced-stack">
<p className="voiced-muted">
Now, Gotham and Open Sauce One are commercial fonts, so Voiced draws them with free look-alikes. If you hold a licence
for the real fonts, import the files here (TTF, OTF or WOFF; one file per weight). They are stored on this computer
only. Invoices you issue keep the fonts they used.
</p>
{error ? <InlineNotification kind="error" lowContrast hideCloseButton title="Font not imported" subtitle={error} /> : null}
{notice ? <InlineNotification kind="info" lowContrast onCloseButtonClick={() => setNotice(null)} title={notice} /> : null}
{loading ? (
<InlineLoading description="Loading fonts…" />
) : (
<Table size="md" useZebraStyles={false} aria-label="Font overrides">
<TableHead>
<TableRow>
<TableHeader>Face</TableHeader>
<TableHeader>Drawn with</TableHeader>
<TableHeader>Used by</TableHeader>
<TableHeader />
</TableRow>
</TableHead>
<TableBody>
{USER_FACES.map((face) => {
const files = fonts.filter((f) => f.face === face);
return (
<TableRow key={face}>
<TableCell>{face}</TableCell>
<TableCell>
{files.length === 0 ? (
<span className="voiced-muted">{FACE_SUBSTITUTES[face]} (bundled substitute)</span>
) : (
<div className="voiced-stack">
{files.map((f) => (
<div key={f.id} style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap" }}>
<span>
{f.fullName || f.familyName} <span className="voiced-muted">({weightName(f.weight, f.style === "italic")}, {f.fileName})</span>
</span>
{!f.hasRupee ? <Tag size="sm" type="gray">no ₹</Tag> : null}
<Button
kind="ghost"
size="sm"
hasIconOnly
renderIcon={TrashCan}
iconDescription={`Remove ${f.fileName}`}
tooltipPosition="left"
disabled={busy !== null}
onClick={() => void remove(f)}
/>
</div>
))}
</div>
)}
</TableCell>
<TableCell>{templatesUsing(face)}</TableCell>
<TableCell>
<FileUploaderButton
labelText={files.length === 0 ? "Import font…" : "Add or replace…"}
buttonKind="tertiary"
size="sm"
accept={[".ttf", ".otf", ".woff"]}
disabled={busy !== null}
disableLabelChanges
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
void choose(face, file);
}}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
{busy && !pending ? <InlineLoading description="Reading the font…" /> : null}
{pending ? <PendingImport pending={pending} onChange={setPending} onImport={() => void doImport()} busy={busy !== null} /> : null}
</div>
);
}
function PendingImport({
pending,
onChange,
onImport,
busy,
}: {
pending: Pending;
onChange: (p: Pending | null) => void;
onImport: () => void;
busy: boolean;
}) {
const { inspection: i } = pending;
return (
<Tile>
<div className="voiced-stack">
<h4>
{i.fullName || i.family} for {pending.face}
</h4>
<p className="voiced-muted">
{pending.fileName} · {i.sourceFormat.toUpperCase()}
{i.sourceFormat === "woff" ? " (unpacked)" : ""} · {(i.size / 1024).toFixed(0)} KB · {i.numGlyphs} glyphs · {i.hasBasicLatin ? "full basic Latin" : "incomplete basic Latin"} ·{" "}
{i.hasRupee ? "has ₹" : "no ₹"}
</p>
{i.warnings.map((w) => (
<InlineNotification key={w} kind="warning" lowContrast hideCloseButton title={w} />
))}
<div style={{ display: "flex", gap: "1rem", alignItems: "flex-end", flexWrap: "wrap" }}>
<Select
id="font-import-weight"
labelText="Weight"
size="sm"
value={String(pending.weight)}
onChange={(e) => onChange({ ...pending, weight: Number(e.target.value) })}
>
{WEIGHTS.map((w) => (
<SelectItem key={w} value={String(w)} text={weightName(w, false)} />
))}
</Select>
<Checkbox id="font-import-italic" labelText="Italic" checked={pending.italic} onChange={(_, { checked }) => onChange({ ...pending, italic: checked })} />
</div>
<Checkbox
id="font-import-licence"
labelText={`I hold a licence that allows me to use this font in my invoices.`}
checked={pending.acknowledged}
onChange={(_, { checked }) => onChange({ ...pending, acknowledged: checked })}
/>
<div className="voiced-header-action">
<Button size="sm" disabled={!pending.acknowledged || busy} onClick={onImport}>
{busy ? "Importing…" : "Import font"}
</Button>
<Button kind="ghost" size="sm" onClick={() => onChange(null)} disabled={busy}>
Cancel
</Button>
</div>
</div>
</Tile>
);
}
+246
View File
@@ -0,0 +1,246 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
Button,
Column,
Grid,
InlineLoading,
Modal,
NumberInput,
Select,
SelectItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TextInput,
} from "@carbon/react";
import { Add, Edit, TrashCan } from "@carbon/icons-react";
import { api } from "../lib/api";
import { UNIT_LABELS, blankPreset, type ItemPreset, type LineMode, type LineUnit } from "../lib/types";
import { paiseToRupees, rupeesToPaise } from "../lib/presets";
import { formatAmount } from "../lib/format";
import { useToast } from "./ToastProvider";
/** Saved line items: description, HSN/SAC, type, unit and amount (kept as integer paise). */
export default function ItemPresetsPanel({ active }: { active: boolean }) {
const toast = useToast();
const [presets, setPresets] = useState<ItemPreset[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<ItemPreset | null>(null);
const [deleting, setDeleting] = useState<ItemPreset | null>(null);
const [busy, setBusy] = useState(false);
const [touched, setTouched] = useState(false);
const launcher = useRef<HTMLElement | null>(null);
const load = useCallback(async () => {
try {
setPresets(await api.listItemPresets());
} catch (e) {
toast.error("Could not load item presets", String(e));
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => {
if (active) void load();
}, [active, load]);
const openEditor = (preset: ItemPreset, trigger: HTMLElement) => {
launcher.current = trigger;
setTouched(false);
setEditing(preset);
};
const set = <K extends keyof ItemPreset>(key: K, value: ItemPreset[K]) =>
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev));
const save = async () => {
if (!editing) return;
setTouched(true);
if (!editing.description.trim()) return;
setBusy(true);
try {
await api.saveItemPreset({ ...editing, description: editing.description.trim() });
toast.success(editing.id === null ? "Preset added" : "Preset updated", editing.description.trim());
setEditing(null);
await load();
} catch (e) {
toast.error("Could not save preset", String(e));
} finally {
setBusy(false);
}
};
const remove = async () => {
if (!deleting || deleting.id === null) return;
setBusy(true);
try {
await api.deleteItemPreset(deleting.id);
toast.success("Preset deleted", deleting.description);
setDeleting(null);
await load();
} catch (e) {
toast.error("Could not delete preset", String(e));
} finally {
setBusy(false);
}
};
if (loading) return <InlineLoading description="Loading item presets" />;
return (
<div className="voiced-stack">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<p className="voiced-muted">Saved line items you can add to an invoice in one click.</p>
<Button
kind="tertiary"
size="sm"
renderIcon={Add}
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(blankPreset(), e.currentTarget)}
>
Add preset
</Button>
</div>
{presets.length === 0 ? (
<div className="voiced-empty">No item presets yet.</div>
) : (
<Table aria-label="Item presets">
<TableHead>
<TableRow>
<TableHeader>Description</TableHeader>
<TableHeader>HSN/SAC</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader>Amount</TableHeader>
<TableHeader>Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{presets.map((p) => (
<TableRow key={p.id ?? p.description}>
<TableCell>{p.description}</TableCell>
<TableCell>{p.hsnSac || "—"}</TableCell>
<TableCell>{p.mode === "rate" ? UNIT_LABELS[p.unit] : "Fixed amount"}</TableCell>
<TableCell>₹{formatAmount(paiseToRupees(p.ratePaise))}</TableCell>
<TableCell>
<div className="voiced-header-action">
<Button
kind="ghost"
size="sm"
renderIcon={Edit}
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor({ ...p }, e.currentTarget)}
>
Edit
</Button>
<Button
kind="ghost"
size="sm"
renderIcon={TrashCan}
onClick={(e: React.MouseEvent<HTMLElement>) => {
launcher.current = e.currentTarget;
setDeleting(p);
}}
>
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Modal
open={editing !== null}
modalHeading={editing?.id ? "Edit item preset" : "New item preset"}
primaryButtonText={busy ? "Saving…" : "Save preset"}
secondaryButtonText="Cancel"
primaryButtonDisabled={busy}
onRequestClose={() => setEditing(null)}
onRequestSubmit={() => void save()}
launcherButtonRef={launcher as React.RefObject<HTMLElement>}
size="sm"
>
{editing ? (
<Grid narrow>
<Column sm={4} md={8} lg={16}>
<TextInput
id="ip-desc"
labelText="Description"
value={editing.description}
onChange={(e) => set("description", e.target.value)}
invalid={touched && !editing.description.trim()}
invalidText="Description is required"
data-modal-primary-focus
/>
</Column>
<Column sm={4} md={4} lg={8}>
<TextInput
id="ip-hsn"
labelText="HSN/SAC (optional)"
value={editing.hsnSac}
onChange={(e) => set("hsnSac", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={8}>
<Select
id="ip-mode"
labelText="Type"
value={editing.mode}
onChange={(e) => set("mode", e.target.value as LineMode)}
>
<SelectItem value="fixed" text="Fixed amount" />
<SelectItem value="rate" text="Rate × quantity" />
</Select>
</Column>
{editing.mode === "rate" ? (
<Column sm={4} md={4} lg={8}>
<Select
id="ip-unit"
labelText="Unit"
value={editing.unit}
onChange={(e) => set("unit", e.target.value as LineUnit)}
>
{Object.entries(UNIT_LABELS).map(([value, text]) => (
<SelectItem key={value} value={value} text={text} />
))}
</Select>
</Column>
) : null}
<Column sm={4} md={4} lg={8}>
<NumberInput
id="ip-rate"
label={editing.mode === "rate" ? "Rate (₹)" : "Amount (₹)"}
min={0}
step={1}
value={paiseToRupees(editing.ratePaise)}
onChange={(_e, { value }) => set("ratePaise", Math.max(0, rupeesToPaise(Number(value))))}
/>
</Column>
</Grid>
) : null}
</Modal>
<Modal
danger
open={deleting !== null}
modalHeading="Delete item preset"
primaryButtonText={busy ? "Deleting…" : "Delete"}
secondaryButtonText="Cancel"
primaryButtonDisabled={busy}
onRequestClose={() => setDeleting(null)}
onRequestSubmit={() => void remove()}
launcherButtonRef={launcher as React.RefObject<HTMLElement>}
size="xs"
>
<p>
Delete the preset “{deleting?.description}”? Invoices already created from it are not affected.
</p>
</Modal>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useState } from "react";
import { InlineNotification, Modal, Select, SelectItem, TextInput } from "@carbon/react";
import { api } from "../lib/api";
import { todayIst } from "../lib/fiscal";
import { formatPaise, paiseToInput, parseRupeesToPaise } from "../lib/money";
import { PAYMENT_MODES, type Payment, type PaymentMode } from "../lib/payments";
import { useReturnFocus } from "../hooks/useReturnFocus";
interface Props {
open: boolean;
invoiceId: number;
invoiceNumber: string;
/** What is still owed, in paise. */
balancePaise: number;
onClose: () => void;
onRecorded: (payment: Payment) => void;
}
/** "Record payment": cash received plus TDS deducted, validated against the balance before it is sent. */
export default function RecordPaymentModal({ open, invoiceId, invoiceNumber, balancePaise, onClose, onRecorded }: Props) {
const [paidOn, setPaidOn] = useState(todayIst());
const [amount, setAmount] = useState("");
const [tds, setTds] = useState("");
const [mode, setMode] = useState<PaymentMode>("bank_transfer");
const [reference, setReference] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useReturnFocus(open);
// Each opening starts from the balance, so the common case (paid in full) is one click.
useEffect(() => {
if (!open) return;
setPaidOn(todayIst());
setAmount(paiseToInput(balancePaise));
setTds("");
setMode("bank_transfer");
setReference("");
setNotes("");
setError(null);
setBusy(false);
}, [open, balancePaise]);
const amountPaise = parseRupeesToPaise(amount);
const tdsPaise = parseRupeesToPaise(tds);
const amountInvalid = amountPaise === null;
const tdsInvalid = tdsPaise === null;
const total = (amountPaise ?? 0) + (tdsPaise ?? 0);
const overBalance = !amountInvalid && !tdsInvalid && total > balancePaise;
const empty = !amountInvalid && !tdsInvalid && total === 0;
const dateInvalid = !/^\d{4}-\d{2}-\d{2}$/.test(paidOn);
const canSubmit = !busy && !amountInvalid && !tdsInvalid && !overBalance && !empty && !dateInvalid;
const submit = async () => {
if (!canSubmit || amountPaise === null || tdsPaise === null) return;
setBusy(true);
setError(null);
try {
const payment = await api.recordPayment({ invoiceId, paidOn, amountPaise, tdsPaise, mode, reference, notes });
onRecorded(payment);
} catch (e) {
setError(String(e));
setBusy(false);
}
};
return (
<Modal
open={open}
modalHeading={`Record payment for ${invoiceNumber}`}
primaryButtonText={busy ? "Saving…" : "Record payment"}
secondaryButtonText="Cancel"
primaryButtonDisabled={!canSubmit}
onRequestClose={onClose}
onRequestSubmit={() => void submit()}
>
<div className="voiced-stack">
<p>
Balance due: <strong>{formatPaise(balancePaise)}</strong>
</p>
{error ? <InlineNotification kind="error" lowContrast hideCloseButton title="Could not record the payment" subtitle={error} /> : null}
<TextInput
id="payDate"
type="date"
labelText="Date received"
value={paidOn}
invalid={dateInvalid}
invalidText="Enter a date"
onChange={(e) => setPaidOn(e.target.value)}
/>
<TextInput
id="payAmount"
inputMode="decimal"
labelText="Amount received (₹)"
helperText="Cash that reached your account"
value={amount}
invalid={amountInvalid || overBalance || empty}
invalidText={
amountInvalid
? "Enter an amount like 1250.50"
: overBalance
? `Amount plus TDS is more than the balance of ${formatPaise(balancePaise)}`
: "Enter an amount received or TDS deducted"
}
onChange={(e) => setAmount(e.target.value)}
/>
<TextInput
id="payTds"
inputMode="decimal"
labelText="TDS deducted by client (₹)"
helperText="Counts towards settling the invoice; leave empty if none"
value={tds}
invalid={tdsInvalid}
invalidText="Enter an amount like 125.00"
onChange={(e) => setTds(e.target.value)}
/>
<Select id="payMode" labelText="Mode" value={mode} onChange={(e) => setMode(e.target.value as PaymentMode)}>
{PAYMENT_MODES.map((m) => (
<SelectItem key={m.id} value={m.id} text={m.label} />
))}
</Select>
<TextInput id="payRef" labelText="UTR / cheque number (optional)" value={reference} onChange={(e) => setReference(e.target.value)} />
<TextInput id="payNotes" labelText="Notes (optional)" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
</Modal>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { createContext, useContext, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
import { ToastNotification } from "@carbon/react";
import { createToastStore, type ToastStore } from "../lib/toastStore";
export interface ToastApi {
success: (title: string, subtitle?: string) => number;
info: (title: string, subtitle?: string) => number;
warning: (title: string, subtitle?: string) => number;
/** Errors stay until the user closes them. */
error: (title: string, subtitle?: string) => number;
dismiss: (id: number) => void;
}
const ToastContext = createContext<ToastApi | null>(null);
function apiFor(store: ToastStore): ToastApi {
return {
success: (t, s) => store.add("success", t, s),
info: (t, s) => store.add("info", t, s),
warning: (t, s) => store.add("warning", t, s),
error: (t, s) => store.add("error", t, s),
dismiss: store.dismiss,
};
}
/** Stacked top-right toasts. Place it inside the Carbon <Theme> so the tokens apply. */
export function ToastProvider({ children }: { children: ReactNode }) {
const storeRef = useRef<ToastStore | null>(null);
if (!storeRef.current) storeRef.current = createToastStore();
const store = storeRef.current;
const toasts = useSyncExternalStore(store.subscribe, store.getSnapshot);
const api = useMemo(() => apiFor(store), [store]);
return (
<ToastContext.Provider value={api}>
{children}
{/* The live region stays mounted so screen readers announce toasts as they are added. */}
<div className="voiced-toasts" aria-live="polite" aria-relevant="additions" role="region" aria-label="Notifications">
{toasts.map((t) => (
<ToastNotification
key={t.id}
kind={t.kind}
lowContrast
title={t.title}
subtitle={t.subtitle}
role={t.kind === "error" ? "alert" : "status"}
onClose={() => {
store.dismiss(t.id);
return false;
}}
onCloseButtonClick={() => store.dismiss(t.id)}
/>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastApi {
const api = useContext(ToastContext);
if (!api) throw new Error("useToast must be used inside <ToastProvider>");
return api;
}
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useRef } from "react";
/**
* Gives focus back to whatever had it when a modal opened, once the modal closes. Carbon's Modal only
* does this with a launcher ref, which does not suit modals opened from table rows or menus.
*/
export function useReturnFocus(open: boolean): void {
const origin = useRef<HTMLElement | null>(null);
const wasOpen = useRef(false);
useEffect(() => {
if (open && !wasOpen.current) {
origin.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
} else if (!open && wasOpen.current) {
const el = origin.current;
origin.current = null;
// After the modal has unmounted its focus trap.
if (el && el.isConnected) setTimeout(() => el.focus(), 0);
}
wasOpen.current = open;
}, [open]);
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
import { api } from "../lib/api";
import { onUserFontsChanged, type UserFontRow } from "../lib/fontImport";
/** The visible user-imported fonts; reloads when the Settings font table changes them. Empty while loading or on error. */
export function useUserFonts(): UserFontRow[] {
const [fonts, setFonts] = useState<UserFontRow[]>([]);
useEffect(() => {
let active = true;
const load = () =>
api
.listUserFonts()
.then((rows) => active && setFonts(rows))
.catch(() => active && setFonts([]));
void load();
const off = onUserFontsChanged(() => void load());
return () => {
active = false;
off();
};
}, []);
return fonts;
}
+38
View File
@@ -7,9 +7,14 @@ import type {
InvoiceInput,
InvoiceSeries,
InvoiceSummary,
ItemPreset,
Settings,
} from "./types";
import type { LogoAsset } from "./logo";
import type { Payment, PaymentInput } from "./payments";
import type { BackupStatus, BackupSummary, PendingRestore } from "./backup";
import type { LedgerRow } from "./historyExport";
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
export interface ArchiveStatus {
archived: boolean;
@@ -35,14 +40,23 @@ export const api = {
saveClient: (client: Client) => invoke<Client>("save_client", { client }),
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
listItemPresets: () => invoke<ItemPreset[]>("list_item_presets"),
saveItemPreset: (preset: ItemPreset) => invoke<ItemPreset>("save_item_preset", { preset }),
deleteItemPreset: (id: number) => invoke<void>("delete_item_preset", { id }),
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
cancelInvoice: (id: number, reason: string) =>
invoke<Invoice>("cancel_invoice", { id, reason }),
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
listInvoiceLedger: () => invoke<LedgerRow[]>("list_invoice_ledger"),
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
recordPayment: (input: PaymentInput) => invoke<Payment>("record_payment", { input }),
listPayments: (invoiceId: number) => invoke<Payment[]>("list_payments", { invoiceId }),
deletePayment: (id: number) => invoke<void>("delete_payment", { id }),
saveDraft: (id: number | null, payload: unknown) =>
invoke<number>("save_draft", { id, payload }),
listDrafts: () => invoke<DraftSummary[]>("list_drafts"),
@@ -70,9 +84,33 @@ export const api = {
readArchive: async (invoiceId: number): Promise<Uint8Array> =>
new Uint8Array(await invoke<ArrayBuffer>("read_archive", { invoiceId })),
archiveStatus: (invoiceId: number) => invoke<ArchiveStatus>("archive_status", { invoiceId }),
// Imported fonts (Now, Gotham, Open Sauce One). The font bytes are the raw body; metadata goes in headers.
inspectFont: (bytes: Uint8Array) => invoke<FontInspection>("inspect_font", bytes),
importFont: (bytes: Uint8Array, meta: ImportMeta) =>
invoke<UserFontRow>("import_font", bytes, {
headers: {
"x-face": encodeURIComponent(meta.face),
"x-file-name": encodeURIComponent(meta.fileName),
"x-licence-ack": String(meta.licenceAcknowledged),
...(meta.weight !== undefined ? { "x-weight": String(meta.weight) } : {}),
...(meta.italic !== undefined ? { "x-style": meta.italic ? "italic" : "normal" } : {}),
},
}),
listUserFonts: (includeHidden = false) => invoke<UserFontRow[]>("list_user_fonts", { includeHidden }),
getUserFontBytes: async (sha256: string): Promise<Uint8Array> =>
new Uint8Array(await invoke<ArrayBuffer>("get_user_font_bytes", { sha256 })),
removeUserFont: (id: number) => invoke<RemoveOutcome>("remove_user_font", { id }),
writeExportFile: (path: string, bytes: Uint8Array) =>
invoke<string>("write_export_file", bytes, { headers: { "x-path": encodeURIComponent(path) } }),
getLastExportDir: () => invoke<string>("get_last_export_dir"),
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
openFile: (path: string) => invoke<void>("open_file", { path }),
// Backup and restore. A restore is staged now and applied by the next start (see commands/backup.rs).
createBackup: (dest: string) => invoke<BackupSummary>("create_backup", { dest }),
restoreBackup: (path: string) => invoke<PendingRestore>("restore_backup", { path }),
cancelPendingRestore: () => invoke<void>("cancel_pending_restore"),
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
restartApp: () => invoke<void>("restart_app"),
};
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { backupFileName, ensureZipExtension, formatBytes, formatWhen } from "./backup";
describe("backup helpers", () => {
it("names backups by local date and time", () => {
expect(backupFileName(new Date(2026, 2, 4, 9, 5))).toBe("voiced-backup-20260304-0905.zip");
});
it("adds the zip extension only when missing", () => {
expect(ensureZipExtension("/a/b")).toBe("/a/b.zip");
expect(ensureZipExtension("/a/b.ZIP")).toBe("/a/b.ZIP");
expect(ensureZipExtension("/a/b.zip")).toBe("/a/b.zip");
});
it("formats sizes", () => {
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(1536)).toBe("1.5 KB");
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
expect(formatBytes(300 * 1024 * 1024)).toBe("300 MB");
});
it("falls back to the raw text for an unparseable time", () => {
expect(formatWhen("not a date")).toBe("not a date");
expect(formatWhen("2026-03-04T10:00:00Z")).toMatch(/2026/);
});
});
+68
View File
@@ -0,0 +1,68 @@
/** Shapes returned by the backup commands (src-tauri/src/commands/backup.rs). */
export interface BackupSummary {
path: string;
createdAt: string;
schemaVersion: number;
fileCount: number;
totalBytes: number;
}
export interface PendingRestore {
backupName: string;
backupCreatedAt: string;
appVersion: string;
schemaVersion: number;
fileCount: number;
totalBytes: number;
stagedAt: string;
}
export interface LastRestore {
ok: boolean;
at: string;
backupName: string;
message: string;
safetyDir: string | null;
}
export interface BackupStatus {
autoBackup: boolean;
autoDir: string;
lastAutoBackup: string | null;
autoBackupCount: number;
pendingRestore: PendingRestore | null;
lastRestore: LastRestore | null;
}
const pad = (n: number) => String(n).padStart(2, "0");
/** `voiced-backup-20260304-1530.zip` in local time. */
export function backupFileName(now: Date = new Date()): string {
const day = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
return `voiced-backup-${day}-${pad(now.getHours())}${pad(now.getMinutes())}.zip`;
}
/** The save dialog does not always add the extension (notably on Linux). */
export function ensureZipExtension(path: string): string {
return /\.zip$/i.test(path) ? path : `${path}.zip`;
}
/** 1536 -> "1.5 KB". Sizes of backup contents are display-only, so plain division is fine. */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let value = bytes / 1024;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[i]}`;
}
/** "4 Mar 2026, 3:30 pm" for an ISO timestamp; the raw text if it does not parse. */
export function formatWhen(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit" });
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { composeAddress, pincodeError, splitLegacyAddress } from "./clientAddress";
describe("composeAddress", () => {
it("joins lines and the city/state/pincode line", () => {
expect(
composeAddress({ line1: "12 MG Road", line2: "Floor 2", city: "Bengaluru", stateCode: "29", pincode: "560001" }),
).toBe("12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
});
it("skips empty parts", () => {
expect(composeAddress({ line1: " A ", line2: "", city: "", stateCode: "", pincode: "560001" })).toBe("A\n560001");
expect(composeAddress({ line1: "", line2: "", city: "Pune", stateCode: "", pincode: "" })).toBe("Pune");
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "29", pincode: "" })).toBe("Karnataka");
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "", pincode: "" })).toBe("");
});
});
describe("splitLegacyAddress", () => {
it("puts the first line in line1 and the rest in line2", () => {
expect(splitLegacyAddress("Old style\nfree text\r\nBangalore")).toEqual({
line1: "Old style",
line2: "free text, Bangalore",
});
expect(splitLegacyAddress("")).toEqual({ line1: "", line2: "" });
});
});
describe("pincodeError", () => {
it("accepts empty and 6 digits only", () => {
expect(pincodeError("")).toBeUndefined();
expect(pincodeError("560001")).toBeUndefined();
expect(pincodeError("56001")).toBeDefined();
expect(pincodeError("56000A")).toBeDefined();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { INDIAN_STATES } from "./types";
export interface AddressParts {
line1: string;
line2: string;
city: string;
stateCode: string;
pincode: string;
}
export const EMPTY_ADDRESS: AddressParts = { line1: "", line2: "", city: "", stateCode: "", pincode: "" };
/**
* The stored multi-line address: line 1, line 2, then "City, State - Pincode".
* Mirrors `compose_address` in src-tauri/src/commands/clients.rs, which is authoritative on save.
*/
export function composeAddress(parts: AddressParts): string {
const stateName = INDIAN_STATES.find((s) => s.code === parts.stateCode)?.name ?? "";
const place = [parts.city.trim(), stateName].filter(Boolean).join(", ");
const pin = parts.pincode.trim();
const last = place && pin ? `${place} - ${pin}` : place || pin;
return [parts.line1.trim(), parts.line2.trim(), last].filter(Boolean).join("\n");
}
/** Best effort for clients saved before structured fields existed: first line to line 1, the rest to line 2. */
export function splitLegacyAddress(address: string): Pick<AddressParts, "line1" | "line2"> {
const lines = address
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
return { line1: lines[0] ?? "", line2: lines.slice(1).join(", ") };
}
export function isPincode(value: string): boolean {
return /^[0-9]{6}$/.test(value.trim());
}
export function pincodeError(value: string): string | undefined {
if (!value.trim()) return undefined;
return isPincode(value) ? undefined : "Pincode is 6 digits";
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { invoiceToFormState } from "./duplicate";
import { fromDraftPayload, toDraftPayload } from "./draft";
import type { Invoice } from "./types";
const invoice = {
id: 7,
number: "INV/2025-007",
invoiceDate: "2025-04-01",
dueDate: "2025-05-01",
clientId: 3,
clientName: "Acme",
clientAddress: "1 Road",
clientGstin: "",
poNumber: "PO-1",
placeOfSupplyStateCode: "29",
discount: 50,
taxRate: 18,
reverseCharge: false,
bankAccountId: 2,
signaturePath: "assets/sig.png",
notes: "Thanks",
items: [
{ id: 11, description: "Design", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 1000, sortOrder: 4, hsnSac: "998" },
],
} as unknown as Invoice;
describe("invoiceToFormState", () => {
it("copies the content with a fresh date, due date and no item ids", () => {
const s = invoiceToFormState(invoice, { paymentTermsDays: 15 }, "2026-02-20");
expect(s.invoiceDate).toBe("2026-02-20");
expect(s.dueDate).toBe("2026-03-07");
expect(s.clientName).toBe("Acme");
expect(s.placeOfSupply).toBe("29");
expect(s.bankId).toBe(2);
expect(s.saveClient).toBe(false);
expect(s.items).toHaveLength(1);
expect(s.items[0]).not.toHaveProperty("id");
expect(s.items[0].sortOrder).toBe(0);
expect(s.items[0].hsnSac).toBe("998");
});
it("survives a round trip through the draft payload", () => {
const s = invoiceToFormState(invoice, { paymentTermsDays: 30 }, "2026-02-20");
expect(fromDraftPayload(toDraftPayload(s), s)).toEqual(s);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { addDays, todayIso } from "./format";
import type { InvoiceFormState } from "./draft";
import type { Invoice, Settings } from "./types";
/**
* The New invoice form state for "Duplicate": every content field of the issued invoice, with today's date,
* a fresh due date, and no link to the original (no id, no number). The result goes through the normal draft
* mechanism (`toDraftPayload` + `saveDraft`), so the original stays immutable.
*/
export function invoiceToFormState(invoice: Invoice, settings: Pick<Settings, "paymentTermsDays">, today = todayIso()): InvoiceFormState {
return {
invoiceDate: today,
dueDate: addDays(today, settings.paymentTermsDays),
clientId: invoice.clientId,
clientName: invoice.clientName,
clientAddress: invoice.clientAddress,
clientGstin: invoice.clientGstin,
poNumber: invoice.poNumber,
placeOfSupply: invoice.placeOfSupplyStateCode,
saveClient: false,
items: invoice.items.map((item, index) => {
const { id: _id, ...rest } = item;
return { ...rest, sortOrder: index };
}),
discount: invoice.discount,
taxRate: invoice.taxRate,
reverseCharge: invoice.reverseCharge,
bankId: invoice.bankAccountId,
signaturePath: invoice.signaturePath,
notes: invoice.notes,
};
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { currentFyStartYear, fyLabel, fyRange, fyStartYear, monthOf, todayIst, FY_MONTHS } from "./fiscal";
describe("financial year", () => {
it("starts on 1 April", () => {
expect(fyStartYear("2026-03-31")).toBe(2025);
expect(fyStartYear("2026-04-01")).toBe(2026);
expect(fyStartYear("2026-12-31")).toBe(2026);
expect(fyStartYear("2027-01-01")).toBe(2026);
});
it("rejects things that are not ISO dates", () => {
expect(fyStartYear("")).toBeNull();
expect(fyStartYear("01/04/2026")).toBeNull();
expect(fyStartYear("2026-13-01")).toBeNull();
expect(monthOf("2026-00-10")).toBeNull();
expect(monthOf("2026-07-10")).toBe(7);
});
it("labels and ranges", () => {
expect(fyLabel(2025)).toBe("FY 2025-26");
expect(fyLabel(2099)).toBe("FY 2099-00");
expect(fyRange(2025)).toEqual({ from: "2025-04-01", to: "2026-03-31" });
});
it("lists months April to March", () => {
expect(FY_MONTHS.map((m) => m.month)).toEqual([4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 2, 3]);
expect(FY_MONTHS[0].label).toBe("April");
});
it("uses IST for now, not the machine time zone", () => {
// 20:00 UTC on 31 March is already 01:30 on 1 April in India: a new financial year.
const now = new Date("2026-03-31T20:00:00Z");
expect(todayIst(now)).toBe("2026-04-01");
expect(currentFyStartYear(now)).toBe(2026);
// 18:29 UTC is still 23:59 on 31 March in India.
expect(currentFyStartYear(new Date("2026-03-31T18:29:00Z"))).toBe(2025);
});
});
+48
View File
@@ -0,0 +1,48 @@
/** Indian financial year (April to March) helpers. Invoice dates are plain YYYY-MM-DD calendar dates, so they
* are read from the string; only "now" needs a time zone, and that is always IST (UTC+05:30). */
const ISO = /^(\d{4})-(\d{2})-(\d{2})$/;
const IST_OFFSET_MS = (5 * 60 + 30) * 60_000;
/** Months in financial-year order, for the month filter. */
export const FY_MONTHS: { month: number; label: string }[] = [4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 2, 3].map((month) => ({
month,
label: new Date(2000, month - 1, 1).toLocaleDateString("en-GB", { month: "long" }),
}));
/** Month 1-12 of an ISO date, or null when it is not one. */
export function monthOf(iso: string): number | null {
const m = ISO.exec(iso);
if (!m) return null;
const month = Number(m[2]);
return month >= 1 && month <= 12 ? month : null;
}
/** The calendar year in which the financial year containing `iso` starts (2026-03-31 -> 2025, 2026-04-01 -> 2026). */
export function fyStartYear(iso: string): number | null {
const m = ISO.exec(iso);
const month = monthOf(iso);
if (!m || month === null) return null;
const year = Number(m[1]);
return month >= 4 ? year : year - 1;
}
/** 2025 -> "FY 2025-26". */
export function fyLabel(startYear: number): string {
return `FY ${startYear}-${String((startYear + 1) % 100).padStart(2, "0")}`;
}
/** First and last day of a financial year. */
export function fyRange(startYear: number): { from: string; to: string } {
return { from: `${startYear}-04-01`, to: `${startYear + 1}-03-31` };
}
/** Today's date in IST as YYYY-MM-DD. */
export function todayIst(now: Date = new Date()): string {
return new Date(now.getTime() + IST_OFFSET_MS).toISOString().slice(0, 10);
}
/** The financial year that `now` falls in, in IST. */
export function currentFyStartYear(now: Date = new Date()): number {
return fyStartYear(todayIst(now)) as number;
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_PREFS } from "../pdf/model/prefs";
import { clientRefusal, selectionFrom, withUserFonts, type UserFontRow } from "./fontImport";
const row = (id: number, face: UserFontRow["face"], sha: string, over: Partial<UserFontRow> = {}): UserFontRow => ({
id, face, familyName: "F", fullName: "F", styleName: "", weight: 400, style: "normal", sha256: sha, fileName: "f.ttf",
format: "ttf", size: 1, fsType: 0, hasRupee: true, licenceAckAt: "t", importedAt: "t", hidden: false, ...over,
});
const A = "a".repeat(64);
const B = "b".repeat(64);
const C = "c".repeat(64);
describe("clientRefusal", () => {
const bytes = (magic: string) => new Uint8Array([...magic].map((c) => c.charCodeAt(0)).concat(new Array(20).fill(0)));
it("passes TTF, OTF and WOFF and refuses the rest with a reason", () => {
expect(clientRefusal(new Uint8Array([0, 1, 0, 0, 0, 0, 0, 0]))).toBeNull();
expect(clientRefusal(bytes("OTTO"))).toBeNull();
expect(clientRefusal(bytes("wOFF"))).toBeNull();
expect(clientRefusal(bytes("wOF2"))).toMatch(/WOFF2/);
expect(clientRefusal(bytes("ttcf"))).toMatch(/collections/);
expect(clientRefusal(bytes("<htm"))).toMatch(/not a TrueType/);
});
});
describe("withUserFonts", () => {
const fonts = [row(1, "Gotham", B), row(2, "Gotham", A, { weight: 700 }), row(3, "Now", C), row(4, "Now", "d".repeat(64), { hidden: true })];
it("lists visible fonts per face", () => {
expect(selectionFrom(fonts)).toEqual({ Gotham: [B, A], Now: [C] });
});
it("adds only the faces the template draws, sorted, and nothing for a template without them", () => {
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "purple-pop" }, fonts).fonts).toEqual({ Gotham: [A, B] });
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "citrus-split" }, fonts).fonts).toEqual({ Now: [C], Gotham: [A, B] });
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "marble" }, fonts).fonts).toEqual({ Now: [C] });
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "classic" }, fonts)).toEqual({ ...DEFAULT_PREFS, templateId: "classic" });
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "marble" }, [])).toEqual({ ...DEFAULT_PREFS, templateId: "marble" });
});
it("replaces fonts already in the prefs rather than merging", () => {
const stale = { ...DEFAULT_PREFS, templateId: "marble", fonts: { Now: ["e".repeat(64)] } };
expect(withUserFonts(stale, fonts).fonts).toEqual({ Now: [C] });
expect(withUserFonts(stale, []).fonts).toBeUndefined();
});
});
+125
View File
@@ -0,0 +1,125 @@
import type { FontFormat, UserFace } from "../pdf/fonts/userFontStore";
import { sniffFontFormat, USER_FACES } from "../pdf/fonts/userFontStore";
import type { RenderPrefsV1 } from "../pdf/model/prefs";
import { catalogEntry } from "../pdf/templates/catalog";
export { USER_FACES, type UserFace };
/** The three licensed faces and the bundled look-alike each one is drawn with until a font is imported. */
export const FACE_SUBSTITUTES: Record<UserFace, string> = {
Now: "Jost",
Gotham: "Montserrat",
"Open Sauce One": "Poppins",
};
/** What the backend found in a font file (src-tauri/src/commands/fonts.rs `FontInspection`). */
export interface FontInspection {
format: "ttf" | "otf";
sourceFormat: "ttf" | "otf" | "woff";
family: string;
subfamily: string;
fullName: string;
postscriptName: string;
weight: number;
italic: boolean;
fsType: number;
numGlyphs: number;
hasRupee: boolean;
hasBasicLatin: boolean;
size: number;
sha256: string;
warnings: string[];
}
/** One stored font file (`UserFont`). */
export interface UserFontRow {
id: number;
face: UserFace;
familyName: string;
fullName: string;
styleName: string;
weight: number;
style: "normal" | "italic";
sha256: string;
fileName: string;
format: "ttf" | "otf";
size: number;
fsType: number;
hasRupee: boolean;
licenceAckAt: string;
importedAt: string;
hidden: boolean;
}
export interface RemoveOutcome {
deleted: boolean;
hidden: boolean;
}
export interface ImportMeta {
face: UserFace;
fileName: string;
weight?: number;
italic?: boolean;
licenceAcknowledged: boolean;
}
/**
* The same magic-byte check the backend makes, to refuse early with a plain message. TTF, OTF and WOFF pass (the
* backend unpacks WOFF); the backend stays the authority and repeats every check.
*/
export function clientRefusal(bytes: Uint8Array): string | null {
const format: FontFormat = sniffFontFormat(bytes);
switch (format) {
case "ttf":
case "otf":
case "woff":
return null;
case "woff2":
return "WOFF2 cannot be used: it embeds in a PDF without glyph outlines (invisible text). Import the TTF, OTF or WOFF file instead.";
case "ttc":
return "Font collections (.ttc) are not supported. Import a single TTF or OTF file.";
default:
return "This is not a TrueType, OpenType or WOFF font file.";
}
}
export const weightName = (weight: number, italic: boolean): string => {
const names: Record<number, string> = { 100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular", 500: "Medium", 600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black" };
return `${names[weight] ?? weight}${italic ? " Italic" : ""}`;
};
/** Visible fonts grouped by face, as the sha256 list `prefs.fonts` carries. */
export function selectionFrom(fonts: readonly UserFontRow[]): NonNullable<RenderPrefsV1["fonts"]> {
const out: NonNullable<RenderPrefsV1["fonts"]> = {};
for (const f of fonts) {
if (f.hidden) continue;
(out[f.face] ??= []).push(f.sha256);
}
return out;
}
/**
* The prefs to render and issue with: `prefs` plus the imported fonts for the faces this template draws. Fonts for
* faces it does not use stay out, so an invoice only pins (and so only protects) the fonts it really uses. The
* result is for rendering; keep the clean `prefs` for the page-setup controls and the saved default.
*/
export function withUserFonts(prefs: RenderPrefsV1, fonts: readonly UserFontRow[]): RenderPrefsV1 {
const { fonts: _drop, ...rest } = prefs;
const faces = catalogEntry(prefs.templateId).faces ?? [];
const selection = selectionFrom(fonts);
const used: NonNullable<RenderPrefsV1["fonts"]> = {};
for (const face of faces) {
const ids = selection[face];
if (ids && ids.length > 0) used[face] = [...ids].sort();
}
return Object.keys(used).length > 0 ? { ...rest, fonts: used } : rest;
}
// Settings and the invoice view are separate parts of the UI; this lets the list in one refresh the other.
const listeners = new Set<() => void>();
export const onUserFontsChanged = (cb: () => void): (() => void) => {
listeners.add(cb);
return () => listeners.delete(cb);
};
export const notifyUserFontsChanged = (): void => listeners.forEach((cb) => cb());
+217
View File
@@ -0,0 +1,217 @@
import { describe, expect, it } from "vitest";
import {
buildHistoryRows,
csvField,
exportHistory,
guardFormula,
HISTORY_COLUMNS,
historyExportName,
paiseToDecimal,
toCsv,
toJson,
type HistoryExportDeps,
type LedgerRow,
} from "./historyExport";
import { NO_FILTERS } from "./historyFilter";
import type { InvoiceSummary } from "./types";
const summary = (over: Partial<InvoiceSummary> = {}): InvoiceSummary => ({
id: 1,
number: "INV/2025-001",
invoiceDate: "2025-06-10",
dueDate: "2025-07-10",
clientName: "Acme, Inc.",
total: 0,
status: "issued",
createdAt: "",
poNumber: "PO-1",
totalPaise: 1_180_050,
paidPaise: 1_000_000,
tdsPaise: 50_000,
balancePaise: 130_050,
paymentStatus: "partially_paid",
...over,
});
const ledger = (over: Partial<LedgerRow> = {}): LedgerRow => ({
id: 1,
clientGstin: "29ABCDE1234F1Z5",
placeOfSupplyCode: "29",
docType: "tax_invoice",
hsnSac: ["9983", "9984", "9983"],
taxablePaise: 1_000_000,
cgstPaise: 90_025,
sgstPaise: 90_025,
igstPaise: 0,
reverseCharge: false,
...over,
});
const columnIndex = (key: string) => HISTORY_COLUMNS.findIndex((c) => c.key === key);
describe("paiseToDecimal", () => {
it("formats from integer paise without float math", () => {
expect(paiseToDecimal(0)).toBe("0.00");
expect(paiseToDecimal(5)).toBe("0.05");
expect(paiseToDecimal(100)).toBe("1.00");
expect(paiseToDecimal(123_450)).toBe("1234.50");
expect(paiseToDecimal(-5)).toBe("-0.05");
expect(paiseToDecimal(-123_456)).toBe("-1234.56");
// 1.005 * 100 style float traps cannot happen: 100_100_000_000_005 paise.
expect(paiseToDecimal(100_100_000_000_005)).toBe("1001000000000.05");
});
it("rejects non-integers", () => {
expect(() => paiseToDecimal(1.5)).toThrow();
expect(() => paiseToDecimal(Number.NaN)).toThrow();
});
});
describe("rows", () => {
it("builds the CA-friendly columns", () => {
const [row] = buildHistoryRows([summary()], [ledger()]);
const get = (key: string) => row[columnIndex(key)];
expect(HISTORY_COLUMNS.map((c) => c.header)).toEqual([
"Invoice No", "Date", "Due Date", "Client", "Client GSTIN", "Place of Supply Code", "Place of Supply",
"Document Type", "HSN/SAC", "Taxable Value", "CGST", "SGST", "IGST", "Total Tax", "Invoice Total",
"Received", "TDS", "Balance", "Status", "Payment Status", "PO Number", "Cancelled",
]);
expect(get("invoiceNo")).toBe("INV/2025-001");
expect(get("placeOfSupplyCode")).toBe("29");
expect(get("placeOfSupply")).toBe("Karnataka");
expect(get("documentType")).toBe("Tax Invoice");
expect(get("hsnSac")).toBe("9983; 9984");
expect(get("taxableValue")).toBe("10000.00");
expect(get("cgst")).toBe("900.25");
expect(get("igst")).toBe("0.00");
expect(get("totalTax")).toBe("1800.50");
expect(get("invoiceTotal")).toBe("11800.50");
expect(get("received")).toBe("10000.00");
expect(get("tds")).toBe("500.00");
expect(get("balance")).toBe("1300.50");
expect(get("status")).toBe("Issued");
expect(get("paymentStatus")).toBe("Partly paid");
expect(get("cancelled")).toBe("No");
});
it("flags cancelled invoices and leaves ledger fields blank when the row is missing", () => {
const [row] = buildHistoryRows([summary({ status: "cancelled", paymentStatus: "none" })], []);
expect(row[columnIndex("cancelled")]).toBe("Yes");
expect(row[columnIndex("status")]).toBe("Cancelled");
expect(row[columnIndex("paymentStatus")]).toBe("");
expect(row[columnIndex("taxableValue")]).toBe("");
expect(row[columnIndex("invoiceTotal")]).toBe("11800.50");
});
});
describe("CSV", () => {
it("quotes per RFC 4180", () => {
expect(csvField("plain")).toBe("plain");
expect(csvField("a,b")).toBe('"a,b"');
expect(csvField('say "hi"')).toBe('"say ""hi"""');
expect(csvField("two\nlines")).toBe('"two\nlines"');
expect(csvField("cr\rhere")).toBe('"cr\rhere"');
expect(csvField(" padded")).toBe('" padded"');
});
it("guards text cells against formula injection but leaves amounts numeric", () => {
for (const bad of ["=1+1", "+SUM(A1)", "-2+3", "@cmd", "\tTAB", "\rCR"]) {
expect(guardFormula(bad)).toBe(`'${bad}`);
}
expect(guardFormula("INV/2025-001")).toBe("INV/2025-001");
const cells = buildHistoryRows(
[summary({ clientName: "=HYPERLINK(\"http://x\")", poNumber: "-5", balancePaise: -2_500, paidPaise: 0 })],
[ledger()],
);
const csv = toCsv(cells);
const dataLine = csv.split("\r\n")[1];
expect(dataLine).toContain(`"'=HYPERLINK(""http://x"")"`);
expect(dataLine).toContain(",'-5,"); // PO number text is guarded
expect(dataLine).toContain(",-25.00,"); // the numeric balance is not
expect(dataLine).not.toContain("'-25.00");
});
it("has a BOM, CRLF line ends and a trailing newline", () => {
const csv = toCsv(buildHistoryRows([summary(), summary({ id: 2, number: "INV/2025-002" })], [ledger(), ledger({ id: 2 })]));
expect(csv.startsWith("Invoice No,Date,")).toBe(true);
expect(csv.endsWith("\r\n")).toBe(true);
expect(csv.split("\r\n")).toHaveLength(4); // header, two rows, empty after the last CRLF
expect(csv.replace(/\r\n/g, "").includes("\n")).toBe(false);
expect(new TextEncoder().encode(csv).slice(0, 3)).toEqual(new Uint8Array([0xef, 0xbb, 0xbf]));
});
it("an empty set is a header-only file", () => {
const csv = toCsv(buildHistoryRows([], []));
expect(csv.split("\r\n")).toEqual([expect.stringMatching(/^Invoice No,.*Cancelled$/), ""]);
});
});
describe("JSON", () => {
it("wraps the rows with schema, time and filters", () => {
const filters = { ...NO_FILTERS, fy: 2025 as const, search: "acme" };
const parsed = JSON.parse(toJson(buildHistoryRows([summary()], [ledger()]), filters, "2026-01-02T03:04:05.000Z"));
expect(parsed.schema).toBe("voiced.history.v1");
expect(parsed.exported_at).toBe("2026-01-02T03:04:05.000Z");
expect(parsed.filters).toEqual(filters);
expect(parsed.count).toBe(1);
expect(parsed.rows[0]).toMatchObject({ invoiceNo: "INV/2025-001", taxableValue: "10000.00", totalTax: "1800.50", cancelled: "No" });
});
it("an empty set has no rows", () => {
const parsed = JSON.parse(toJson([], NO_FILTERS, "t"));
expect(parsed.rows).toEqual([]);
expect(parsed.count).toBe(0);
});
});
describe("historyExportName", () => {
it("uses the financial year when one is picked", () => {
expect(historyExportName({ ...NO_FILTERS, fy: 2025 }, [], "csv")).toBe("voiced-history-FY2025-26.csv");
expect(historyExportName({ ...NO_FILTERS, fy: 2025, month: 4 }, [], "json")).toBe("voiced-history-FY2025-26-m04.json");
});
it("otherwise the date range of the rows", () => {
const rows = [summary({ invoiceDate: "2025-06-10" }), summary({ invoiceDate: "2025-04-02" })];
expect(historyExportName(NO_FILTERS, rows, "csv")).toBe("voiced-history-2025-04-02_to_2025-06-10.csv");
expect(historyExportName(NO_FILTERS, [rows[0]], "csv")).toBe("voiced-history-2025-06-10.csv");
expect(historyExportName(NO_FILTERS, [], "csv")).toBe("voiced-history-all.csv");
});
});
describe("exportHistory", () => {
const make = (chosen: string | null) => {
const written: { path: string; text: string }[] = [];
const saves: { defaultPath: string; format: string }[] = [];
const deps: HistoryExportDeps = {
listLedger: async () => [ledger()],
getLastExportDir: async () => "/home/a/Exports",
save: async (o) => {
saves.push(o);
return chosen;
},
writeExportFile: async (path, bytes) => {
written.push({ path, text: new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes) });
return path;
},
now: () => new Date("2026-01-02T03:04:05Z"),
};
return { deps, written, saves };
};
it("writes exactly the rows given, through the save dialog", async () => {
const { deps, written, saves } = make("/home/a/Exports/out.csv");
const out = await exportHistory(deps, [summary()], { ...NO_FILTERS, fy: 2025 }, "csv");
expect(out).toEqual({ path: "/home/a/Exports/out.csv", count: 1 });
expect(saves[0]).toEqual({ defaultPath: "/home/a/Exports/voiced-history-FY2025-26.csv", format: "csv" });
expect(written).toHaveLength(1);
expect(written[0].text.startsWith("Invoice No")).toBe(true);
expect(written[0].text.split("\r\n")).toHaveLength(3);
});
it("writes nothing when the dialog is cancelled, and a header-only file for an empty set", async () => {
const cancelled = make(null);
expect(await exportHistory(cancelled.deps, [summary()], NO_FILTERS, "json")).toBeNull();
expect(cancelled.written).toEqual([]);
const empty = make("/x/e.json");
expect(await exportHistory(empty.deps, [], NO_FILTERS, "json")).toEqual({ path: "/x/e.json", count: 0 });
expect(JSON.parse(empty.written[0].text).rows).toEqual([]);
});
});
+183
View File
@@ -0,0 +1,183 @@
import { fyLabel } from "./fiscal";
import type { HistoryFilters } from "./historyFilter";
import { INDIAN_STATES, type InvoiceSummary } from "./types";
/** Per-invoice fields the History list does not carry (from the `list_invoice_ledger` command). Money is integer paise. */
export interface LedgerRow {
id: number;
clientGstin: string;
placeOfSupplyCode: string;
docType: string;
hsnSac: string[];
taxablePaise: number;
cgstPaise: number;
sgstPaise: number;
igstPaise: number;
reverseCharge: boolean;
}
export const HISTORY_SCHEMA = "voiced.history.v1";
/** 123450 -> "1234.50", -5 -> "-0.05". Integer arithmetic only; throws on anything that is not a safe integer. */
export function paiseToDecimal(paise: number): string {
if (!Number.isSafeInteger(paise)) throw new Error(`Not an amount in paise: ${paise}`);
const abs = Math.abs(paise);
const whole = Math.floor(abs / 100);
const frac = abs % 100;
return `${paise < 0 ? "-" : ""}${whole}.${frac < 10 ? "0" : ""}${frac}`;
}
/** Text cells are guarded against spreadsheet formula injection; amount cells are known numbers and stay numeric. */
type Kind = "text" | "amount";
interface Column {
key: string;
header: string;
kind: Kind;
value: (s: InvoiceSummary, l: LedgerRow | undefined) => string;
}
const DOC_TYPES: Record<string, string> = { tax_invoice: "Tax Invoice", invoice: "Invoice" };
const PAYMENT_STATUS: Record<string, string> = {
unpaid: "Unpaid",
partially_paid: "Partly paid",
paid: "Paid",
overdue: "Overdue",
none: "",
};
const STATUS: Record<string, string> = { issued: "Issued", cancelled: "Cancelled", draft: "Draft" };
/** An amount that comes from the ledger row; blank (not a made-up zero) if the row is missing. */
const ledgerAmount = (pick: (l: LedgerRow) => number) => (_s: InvoiceSummary, l: LedgerRow | undefined) =>
l ? paiseToDecimal(pick(l)) : "";
/**
* Column order and meaning for both formats. Cancelled invoices keep their figures (the document exists and
* keeps its number); filter on the Cancelled column to leave them out of a sum.
*/
export const HISTORY_COLUMNS: readonly Column[] = [
{ key: "invoiceNo", header: "Invoice No", kind: "text", value: (s) => s.number },
{ key: "date", header: "Date", kind: "text", value: (s) => s.invoiceDate },
{ key: "dueDate", header: "Due Date", kind: "text", value: (s) => s.dueDate },
{ key: "client", header: "Client", kind: "text", value: (s) => s.clientName },
{ key: "clientGstin", header: "Client GSTIN", kind: "text", value: (_s, l) => l?.clientGstin ?? "" },
{ key: "placeOfSupplyCode", header: "Place of Supply Code", kind: "text", value: (_s, l) => l?.placeOfSupplyCode ?? "" },
{
key: "placeOfSupply",
header: "Place of Supply",
kind: "text",
value: (_s, l) => INDIAN_STATES.find((st) => st.code === l?.placeOfSupplyCode)?.name ?? "",
},
{ key: "documentType", header: "Document Type", kind: "text", value: (_s, l) => (l ? (DOC_TYPES[l.docType] ?? l.docType) : "") },
{ key: "hsnSac", header: "HSN/SAC", kind: "text", value: (_s, l) => (l ? [...new Set(l.hsnSac)].join("; ") : "") },
{ key: "taxableValue", header: "Taxable Value", kind: "amount", value: ledgerAmount((l) => l.taxablePaise) },
{ key: "cgst", header: "CGST", kind: "amount", value: ledgerAmount((l) => l.cgstPaise) },
{ key: "sgst", header: "SGST", kind: "amount", value: ledgerAmount((l) => l.sgstPaise) },
{ key: "igst", header: "IGST", kind: "amount", value: ledgerAmount((l) => l.igstPaise) },
{ key: "totalTax", header: "Total Tax", kind: "amount", value: ledgerAmount((l) => l.cgstPaise + l.sgstPaise + l.igstPaise) },
{ key: "invoiceTotal", header: "Invoice Total", kind: "amount", value: (s) => paiseToDecimal(s.totalPaise) },
{ key: "received", header: "Received", kind: "amount", value: (s) => paiseToDecimal(s.paidPaise) },
{ key: "tds", header: "TDS", kind: "amount", value: (s) => paiseToDecimal(s.tdsPaise) },
{ key: "balance", header: "Balance", kind: "amount", value: (s) => paiseToDecimal(s.balancePaise) },
{ key: "status", header: "Status", kind: "text", value: (s) => STATUS[s.status] ?? s.status },
{ key: "paymentStatus", header: "Payment Status", kind: "text", value: (s) => PAYMENT_STATUS[s.paymentStatus] ?? s.paymentStatus },
{ key: "poNumber", header: "PO Number", kind: "text", value: (s) => s.poNumber },
{ key: "cancelled", header: "Cancelled", kind: "text", value: (s) => (s.status === "cancelled" ? "Yes" : "No") },
];
/** The cells of every exported row, in column order. `ledger` is looked up by invoice id. */
export function buildHistoryRows(summaries: InvoiceSummary[], ledger: LedgerRow[]): string[][] {
const byId = new Map(ledger.map((l) => [l.id, l]));
return summaries.map((s) => HISTORY_COLUMNS.map((c) => c.value(s, byId.get(s.id))));
}
/** A text cell that a spreadsheet would read as a formula gets a leading apostrophe. */
export function guardFormula(text: string): string {
return /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
}
/** RFC 4180 field: quoted when it holds a quote, comma or line break (or edge spaces), inner quotes doubled. */
export function csvField(text: string): string {
return /[",\r\n]|^\s|\s$/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
/** UTF-8 BOM (so Excel reads it as UTF-8), CRLF line ends, header row always present. */
export function toCsv(cells: string[][]): string {
const lines = [HISTORY_COLUMNS.map((c) => csvField(c.header))];
for (const row of cells) {
lines.push(
row.map((value, i) => {
const column = HISTORY_COLUMNS[i];
return csvField(column.kind === "text" ? guardFormula(value) : value);
}),
);
}
return `${lines.map((l) => l.join(",")).join("\r\n")}\r\n`;
}
/**
* `{ schema, exported_at, filters, count, rows }`. Rows are objects keyed like the CSV columns; amounts are the same
* 2-decimal rupee strings as in the CSV (no binary floats anywhere).
*/
export function toJson(cells: string[][], filters: HistoryFilters, exportedAt: string): string {
const rows = cells.map((row) => Object.fromEntries(HISTORY_COLUMNS.map((c, i) => [c.key, row[i]])));
return `${JSON.stringify({ schema: HISTORY_SCHEMA, exported_at: exportedAt, filters, count: rows.length, rows }, null, 2)}\n`;
}
const safeName = (s: string) => s.replace(/[^A-Za-z0-9._-]+/g, "-");
/** `voiced-history-FY2025-26.csv`, or the date range of the rows when no year is picked, or `all` when empty. */
export function historyExportName(
filters: HistoryFilters,
summaries: InvoiceSummary[],
ext: "csv" | "json",
): string {
let part: string;
if (filters.fy !== "all") {
part = fyLabel(filters.fy).replace(/^FY /, "FY");
if (filters.month !== "all") part += `-m${String(filters.month).padStart(2, "0")}`;
} else {
const dates = summaries.map((s) => s.invoiceDate).filter(Boolean).sort();
if (dates.length === 0) part = "all";
else part = dates[0] === dates[dates.length - 1] ? dates[0] : `${dates[0]}_to_${dates[dates.length - 1]}`;
}
return `voiced-history-${safeName(part)}.${ext}`;
}
export type HistoryFormat = "csv" | "json";
export interface HistoryExportDeps {
listLedger(): Promise<LedgerRow[]>;
getLastExportDir(): Promise<string>;
save(opts: { defaultPath: string; format: HistoryFormat }): Promise<string | null>;
writeExportFile(path: string, bytes: Uint8Array): Promise<string>;
now(): Date;
}
function joinPath(dir: string, name: string): string {
if (!dir) return name;
if (/[\\/]$/.test(dir)) return `${dir}${name}`;
return `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
}
/** Exports exactly `summaries` (the filtered, sorted rows) through the save dialog. Returns the path, or null if cancelled. */
export async function exportHistory(
deps: HistoryExportDeps,
summaries: InvoiceSummary[],
filters: HistoryFilters,
format: HistoryFormat,
): Promise<{ path: string; count: number } | null> {
const ledger = await deps.listLedger();
const cells = buildHistoryRows(summaries, ledger);
const text = format === "csv" ? toCsv(cells) : toJson(cells, filters, deps.now().toISOString());
let dir = "";
try {
dir = await deps.getLastExportDir();
} catch {
// No remembered folder: the dialog opens wherever the system prefers.
}
const chosen = await deps.save({ defaultPath: joinPath(dir, historyExportName(filters, summaries, format)), format });
if (!chosen) return null;
const path = (await deps.writeExportFile(chosen, new TextEncoder().encode(text))) || chosen;
return { path, count: cells.length };
}
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { clientOptions, filterInvoices, fyOptions, fySummary, NO_FILTERS, sortInvoices, totalsOf } from "./historyFilter";
import type { InvoiceSummary } from "./types";
let nextId = 1;
function row(over: Partial<InvoiceSummary>): InvoiceSummary {
const base: InvoiceSummary = {
id: nextId++,
number: "INV/2025-001",
invoiceDate: "2025-06-10",
dueDate: "2025-07-10",
clientName: "Acme",
total: 0,
status: "issued",
createdAt: "",
poNumber: "",
totalPaise: 100_000,
paidPaise: 0,
tdsPaise: 0,
balancePaise: 100_000,
paymentStatus: "unpaid",
};
return { ...base, ...over };
}
const rows: InvoiceSummary[] = [
row({ number: "INV/2025-001", invoiceDate: "2025-04-01", clientName: "Acme", poNumber: "PO-77", paymentStatus: "paid", paidPaise: 90_000, tdsPaise: 10_000, balancePaise: 0 }),
row({ number: "INV/2025-002", invoiceDate: "2025-05-20", clientName: "Beta Labs", totalPaise: 250_050, balancePaise: 250_050, paymentStatus: "overdue" }),
row({ number: "INV/2025-010", invoiceDate: "2026-03-31", clientName: "Acme", paidPaise: 30_000, balancePaise: 70_000, paymentStatus: "partially_paid" }),
row({ number: "INV/2026-001", invoiceDate: "2026-04-02", clientName: "Gamma", status: "cancelled", balancePaise: 0, paymentStatus: "none" }),
row({ number: "INV/2026-002", invoiceDate: "2026-04-05", clientName: "Acme", paymentStatus: "unpaid" }),
];
const f = (over: object) => filterInvoices(rows, { ...NO_FILTERS, ...over }).map((r) => r.number);
describe("filterInvoices", () => {
it("returns everything with no filters", () => expect(f({})).toHaveLength(5));
it("searches number, client and PO case-insensitively", () => {
expect(f({ search: "beta" })).toEqual(["INV/2025-002"]);
expect(f({ search: "po-77" })).toEqual(["INV/2025-001"]);
expect(f({ search: " 2026-00 " })).toEqual(["INV/2026-001", "INV/2026-002"]);
expect(f({ search: "nothing" })).toEqual([]);
});
it("filters by status and payment status", () => {
expect(f({ status: "cancelled" })).toEqual(["INV/2026-001"]);
expect(f({ status: "issued" })).toHaveLength(4);
expect(f({ status: "paid" })).toEqual(["INV/2025-001"]);
expect(f({ status: "overdue" })).toEqual(["INV/2025-002"]);
expect(f({ status: "partially_paid" })).toEqual(["INV/2025-010"]);
// An overdue invoice with nothing received is also unpaid.
expect(f({ status: "unpaid" })).toEqual(["INV/2025-002", "INV/2026-002"]);
});
it("filters by financial year, month and client", () => {
expect(f({ fy: 2025 })).toEqual(["INV/2025-001", "INV/2025-002", "INV/2025-010"]);
expect(f({ fy: 2026 })).toEqual(["INV/2026-001", "INV/2026-002"]);
expect(f({ month: 4 })).toEqual(["INV/2025-001", "INV/2026-001", "INV/2026-002"]);
expect(f({ fy: 2026, month: 4 })).toEqual(["INV/2026-001", "INV/2026-002"]);
expect(f({ client: "Acme", fy: 2025 })).toEqual(["INV/2025-001", "INV/2025-010"]);
});
});
describe("sortInvoices", () => {
const nums = (sort: Parameters<typeof sortInvoices>[1]) => sortInvoices(rows, sort).map((r) => r.number);
it("sorts numbers naturally, so 010 follows 002", () => {
expect(nums({ key: "number", dir: "asc" }).slice(0, 3)).toEqual(["INV/2025-001", "INV/2025-002", "INV/2025-010"]);
expect(nums({ key: "number", dir: "desc" })[0]).toBe("INV/2026-002");
});
it("sorts by date, total and balance", () => {
expect(nums({ key: "date", dir: "desc" })[0]).toBe("INV/2026-002");
expect(nums({ key: "total", dir: "desc" })[0]).toBe("INV/2025-002");
expect(nums({ key: "balance", dir: "asc" }).slice(0, 2).sort()).toEqual(["INV/2025-001", "INV/2026-001"]);
});
it("sorts by status with overdue first and cancelled last, and does not mutate", () => {
const before = rows.map((r) => r.id);
const s = sortInvoices(rows, { key: "status", dir: "asc" });
expect(s[0].paymentStatus).toBe("overdue");
expect(s[s.length - 1].status).toBe("cancelled");
expect(rows.map((r) => r.id)).toEqual(before);
});
it("breaks ties by newest id", () => {
const tied = [row({ clientName: "Same" }), row({ clientName: "Same" })];
expect(sortInvoices(tied, { key: "client", dir: "asc" })[0].id).toBe(tied[1].id);
});
});
describe("totals", () => {
it("adds paise exactly and ignores cancelled invoices", () => {
const t = totalsOf(rows);
expect(t.count).toBe(5);
expect(t.invoicedPaise).toBe(100_000 + 250_050 + 100_000 + 100_000);
expect(t.receivedPaise).toBe(120_000);
expect(t.tdsPaise).toBe(10_000);
expect(t.outstandingPaise).toBe(250_050 + 70_000 + 100_000);
});
it("keeps cents exact where floats would drift", () => {
const many = Array.from({ length: 1000 }, () => row({ totalPaise: 10, balancePaise: 10 }));
expect(totalsOf(many).invoicedPaise).toBe(10_000);
});
it("summarises one financial year including overdue", () => {
const s = fySummary(rows, 2025);
expect(s.count).toBe(3);
expect(s.overdueCount).toBe(1);
expect(s.overduePaise).toBe(250_050);
expect(fySummary(rows, 2030).invoicedPaise).toBe(0);
});
});
describe("options", () => {
it("lists financial years newest first and always the current one", () => {
expect(fyOptions(rows, 2025)).toEqual([2026, 2025]);
expect(fyOptions([], 2027)).toEqual([2027]);
});
it("lists distinct clients", () => expect(clientOptions(rows)).toEqual(["Acme", "Beta Labs", "Gamma"]));
});
+145
View File
@@ -0,0 +1,145 @@
import { fyStartYear, monthOf } from "./fiscal";
import type { InvoiceSummary } from "./types";
export type StatusFilter = "all" | "issued" | "cancelled" | "unpaid" | "partially_paid" | "paid" | "overdue";
export const STATUS_FILTERS: { id: StatusFilter; label: string }[] = [
{ id: "all", label: "All statuses" },
{ id: "issued", label: "Issued" },
{ id: "unpaid", label: "Unpaid" },
{ id: "partially_paid", label: "Partly paid" },
{ id: "paid", label: "Paid" },
{ id: "overdue", label: "Overdue" },
{ id: "cancelled", label: "Cancelled" },
];
export interface HistoryFilters {
search: string;
status: StatusFilter;
/** Financial-year start year, or "all". */
fy: number | "all";
/** Calendar month 1-12, or "all". */
month: number | "all";
client: string | "all";
}
export const NO_FILTERS: HistoryFilters = { search: "", status: "all", fy: "all", month: "all", client: "all" };
export type SortKey = "number" | "date" | "client" | "total" | "balance" | "status";
export interface SortSpec {
key: SortKey;
dir: "asc" | "desc";
}
/** All amounts are integer paise. Cancelled invoices count for nothing. */
export interface Totals {
count: number;
invoicedPaise: number;
receivedPaise: number;
tdsPaise: number;
outstandingPaise: number;
}
const settled = (r: InvoiceSummary) => r.paidPaise + r.tdsPaise;
function matchesStatus(r: InvoiceSummary, f: StatusFilter): boolean {
switch (f) {
case "all":
return true;
case "issued":
return r.status === "issued";
case "cancelled":
return r.status === "cancelled";
case "unpaid":
return r.status === "issued" && settled(r) === 0;
case "partially_paid":
return r.status === "issued" && settled(r) > 0 && r.balancePaise > 0;
case "paid":
return r.status === "issued" && r.paymentStatus === "paid";
case "overdue":
return r.status === "issued" && r.paymentStatus === "overdue";
}
}
/** Rows that satisfy every filter. The search is a case-insensitive substring of number, client or PO number. */
export function filterInvoices(rows: InvoiceSummary[], f: HistoryFilters): InvoiceSummary[] {
const q = f.search.trim().toLowerCase();
return rows.filter((r) => {
if (q && !`${r.number}\n${r.clientName}\n${r.poNumber}`.toLowerCase().includes(q)) return false;
if (!matchesStatus(r, f.status)) return false;
if (f.fy !== "all" && fyStartYear(r.invoiceDate) !== f.fy) return false;
if (f.month !== "all" && monthOf(r.invoiceDate) !== f.month) return false;
if (f.client !== "all" && r.clientName !== f.client) return false;
return true;
});
}
const STATUS_RANK: Record<string, number> = { overdue: 0, unpaid: 1, partially_paid: 2, paid: 3, none: 4 };
const statusRank = (r: InvoiceSummary) => (r.status === "cancelled" ? 5 : (STATUS_RANK[r.paymentStatus] ?? 4));
const numeric = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
function compare(a: InvoiceSummary, b: InvoiceSummary, key: SortKey): number {
switch (key) {
case "number":
return numeric.compare(a.number, b.number);
case "date":
return a.invoiceDate < b.invoiceDate ? -1 : a.invoiceDate > b.invoiceDate ? 1 : 0;
case "client":
return numeric.compare(a.clientName, b.clientName);
case "total":
return a.totalPaise - b.totalPaise;
case "balance":
return a.balancePaise - b.balancePaise;
case "status":
return statusRank(a) - statusRank(b);
}
}
/** A new, stably sorted array; ties fall back to the newest invoice first so the order never jitters. */
export function sortInvoices(rows: InvoiceSummary[], sort: SortSpec): InvoiceSummary[] {
const sign = sort.dir === "asc" ? 1 : -1;
return [...rows].sort((a, b) => sign * compare(a, b, sort.key) || b.id - a.id);
}
export function totalsOf(rows: InvoiceSummary[]): Totals {
const t: Totals = { count: rows.length, invoicedPaise: 0, receivedPaise: 0, tdsPaise: 0, outstandingPaise: 0 };
for (const r of rows) {
if (r.status === "cancelled") continue;
t.invoicedPaise += r.totalPaise;
t.receivedPaise += r.paidPaise;
t.tdsPaise += r.tdsPaise;
t.outstandingPaise += r.balancePaise;
}
return t;
}
export interface FySummary extends Totals {
overdueCount: number;
overduePaise: number;
}
/** The tiles above the table: everything dated in one financial year, regardless of the other filters. */
export function fySummary(rows: InvoiceSummary[], fy: number): FySummary {
const inYear = rows.filter((r) => fyStartYear(r.invoiceDate) === fy);
const overdue = inYear.filter((r) => r.status === "issued" && r.paymentStatus === "overdue");
return {
...totalsOf(inYear),
overdueCount: overdue.length,
overduePaise: overdue.reduce((sum, r) => sum + r.balancePaise, 0),
};
}
/** Financial years that have invoices, newest first, always including `current`. */
export function fyOptions(rows: InvoiceSummary[], current: number): number[] {
const years = new Set<number>([current]);
for (const r of rows) {
const y = fyStartYear(r.invoiceDate);
if (y !== null) years.add(y);
}
return [...years].sort((a, b) => b - a);
}
/** Distinct client names, alphabetical, blanks left out. */
export function clientOptions(rows: InvoiceSummary[]): string[] {
return [...new Set(rows.map((r) => r.clientName.trim()).filter(Boolean))].sort((a, b) => numeric.compare(a, b));
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { formatPaise, paiseToInput, parseRupeesToPaise } from "./money";
describe("money", () => {
it("parses typed amounts to exact paise", () => {
expect(parseRupeesToPaise("1250.5")).toBe(125050);
expect(parseRupeesToPaise("1,25,000")).toBe(12500000);
expect(parseRupeesToPaise("₹ 99.99")).toBe(9999);
expect(parseRupeesToPaise("0.29")).toBe(29);
expect(parseRupeesToPaise("19.9")).toBe(1990);
expect(parseRupeesToPaise("5.")).toBe(500);
expect(parseRupeesToPaise("")).toBe(0);
});
it("rejects non-amounts", () => {
for (const bad of ["abc", "-5", "1.234", "1e3", "1.2.3", ".5"]) expect(parseRupeesToPaise(bad)).toBeNull();
expect(parseRupeesToPaise("99999999999999999999")).toBeNull();
});
it("formats", () => {
expect(paiseToInput(123450)).toBe("1234.50");
expect(paiseToInput(5)).toBe("0.05");
expect(formatPaise(12500000)).toBe("₹1,25,000.00");
});
});
+27
View File
@@ -0,0 +1,27 @@
import { formatAmount } from "./format";
/**
* Parses what a person types into an amount field ("1,25,000", "1250.5", "₹ 99.99") into integer paise
* without going through floating point. Returns null for anything that is not a plain amount with at
* most two decimals; an empty string is 0.
*/
export function parseRupeesToPaise(text: string): number | null {
const cleaned = text.replace(/[₹,\s]/g, "");
if (cleaned === "") return 0;
const m = /^(\d+)(?:\.(\d{0,2}))?$/.exec(cleaned);
if (!m) return null;
const whole = Number(m[1]);
const paise = whole * 100 + Number((m[2] ?? "").padEnd(2, "0"));
return Number.isSafeInteger(paise) ? paise : null;
}
/** 123450 -> "1234.50", for filling an amount field. */
export function paiseToInput(paise: number): string {
const p = Math.max(0, Math.round(paise));
return `${Math.floor(p / 100)}.${String(p % 100).padStart(2, "0")}`;
}
/** 123450 -> "₹1,234.50" */
export function formatPaise(paise: number): string {
return `₹${formatAmount(paise / 100)}`;
}
+46
View File
@@ -0,0 +1,46 @@
import type { InvoiceStatus, PaymentStatus } from "./types";
export type PaymentMode = "bank_transfer" | "upi" | "cash" | "cheque" | "other";
export const PAYMENT_MODES: { id: PaymentMode; label: string }[] = [
{ id: "bank_transfer", label: "Bank transfer" },
{ id: "upi", label: "UPI" },
{ id: "cash", label: "Cash" },
{ id: "cheque", label: "Cheque" },
{ id: "other", label: "Other" },
];
export const paymentModeLabel = (mode: string): string => PAYMENT_MODES.find((m) => m.id === mode)?.label ?? mode;
/** A payment as stored: amounts are integer paise. `amountPaise` is cash; `tdsPaise` is tax the client deducted. */
export interface Payment {
id: number;
invoiceId: number;
paidOn: string;
amountPaise: number;
tdsPaise: number;
mode: PaymentMode;
reference: string;
notes: string;
createdAt: string;
}
export type PaymentInput = Omit<Payment, "id" | "createdAt">;
type TagType = "red" | "green" | "blue" | "gray" | "warm-gray" | "cool-gray" | "purple";
/** What to show in the status column and badges: the cancelled state wins over payment state. */
export function statusBadge(status: InvoiceStatus, payment: PaymentStatus | undefined): { label: string; type: TagType } {
if (status === "cancelled") return { label: "Cancelled", type: "gray" };
if (status === "draft") return { label: "Draft", type: "warm-gray" };
switch (payment) {
case "paid":
return { label: "Paid", type: "green" };
case "partially_paid":
return { label: "Partly paid", type: "blue" };
case "overdue":
return { label: "Overdue", type: "red" };
default:
return { label: "Unpaid", type: "cool-gray" };
}
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { itemFromPreset, paiseToRupees, rupeesToPaise } from "./presets";
import { blankPreset } from "./types";
describe("preset money helpers", () => {
it("rounds rupees to whole paise", () => {
expect(rupeesToPaise(1500.5)).toBe(150050);
expect(rupeesToPaise(0.1 + 0.2)).toBe(30);
expect(rupeesToPaise(19.99)).toBe(1999);
expect(rupeesToPaise(Number.NaN)).toBe(0);
expect(paiseToRupees(150050)).toBe(1500.5);
});
});
describe("itemFromPreset", () => {
it("fills a fixed line", () => {
const item = itemFromPreset({ ...blankPreset(), description: "Retainer", mode: "fixed", ratePaise: 250000, hsnSac: "9983" }, { hsnSac: "0000" });
expect(item).toMatchObject({ description: "Retainer", mode: "fixed", amount: 2500, rate: 0, quantity: 1, hsnSac: "9983" });
});
it("fills a rate line and falls back to the default HSN/SAC", () => {
const item = itemFromPreset({ ...blankPreset(), description: "Hours", mode: "rate", unit: "hour", ratePaise: 150050 }, { hsnSac: "998397" });
expect(item).toMatchObject({ mode: "rate", unit: "hour", rate: 1500.5, amount: 0, quantity: 1, hsnSac: "998397" });
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { InvoiceItem, ItemPreset } from "./types";
export const rupeesToPaise = (rupees: number): number => Math.round((Number(rupees) || 0) * 100);
export const paiseToRupees = (paise: number): number => paise / 100;
/**
* A new invoice line pre-filled from a preset. Invoice items store rupee floats, so the integer-paise
* preset amount is converted only here.
*/
export function itemFromPreset(preset: ItemPreset, defaults: { hsnSac: string }): InvoiceItem {
const rupees = paiseToRupees(preset.ratePaise);
return {
description: preset.description,
mode: preset.mode,
rate: preset.mode === "rate" ? rupees : 0,
unit: preset.unit,
quantity: 1,
amount: preset.mode === "fixed" ? rupees : 0,
sortOrder: 0,
hsnSac: preset.hsnSac || defaults.hsnSac,
};
}
+12 -1
View File
@@ -1,11 +1,22 @@
import { createAutoClient, type RenderClient } from "../pdf/render/client";
import type { UserFace } from "../pdf/fonts/userFontStore";
import { api } from "./api";
let client: RenderClient | null = null;
let lastId = 0;
/** The one render client (and so one worker) shared by the preview and export. */
export function getRenderClient(): RenderClient {
client ??= createAutoClient();
client ??= createAutoClient({
// Font metadata comes from the database row, the bytes over the binary IPC; both by the file's sha256.
loadUserFont: async (sha256) => {
const rows = await api.listUserFonts(true);
const row = rows.find((r) => r.sha256 === sha256);
if (!row) throw new Error("An imported font used by this invoice is no longer in the font library");
const bytes = await api.getUserFontBytes(sha256);
return { face: row.face as UserFace, weight: row.weight, italic: row.style === "italic", sha256, bytes };
},
});
return client;
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { SETTINGS_TABS, loadSettingsTab, parseSettingsTab, saveSettingsTab } from "./settingsTab";
describe("settings tab persistence", () => {
it("maps stored ids to indexes and falls back to the first tab", () => {
expect(parseSettingsTab("fonts")).toBe(SETTINGS_TABS.findIndex((t) => t.id === "fonts"));
expect(parseSettingsTab("nope")).toBe(0);
expect(parseSettingsTab(null)).toBe(0);
});
it("does not throw when storage is unavailable", () => {
// The node test environment has no localStorage at all.
expect(loadSettingsTab()).toBe(0);
expect(() => saveSettingsTab(2)).not.toThrow();
expect(() => saveSettingsTab(99)).not.toThrow();
});
});
+38
View File
@@ -0,0 +1,38 @@
const KEY = "voiced.settingsTab";
export const SETTINGS_TABS = [
{ id: "business", label: "Business" },
{ id: "branding", label: "Branding" },
{ id: "bank", label: "Bank & payments" },
{ id: "defaults", label: "Invoice defaults" },
{ id: "numbering", label: "Numbering" },
{ id: "templates", label: "Templates & export" },
{ id: "fonts", label: "Fonts" },
{ id: "data", label: "Data" },
] as const;
export type SettingsTabId = (typeof SETTINGS_TABS)[number]["id"];
/** Index of a stored tab id; 0 (Business) for anything unknown. */
export function parseSettingsTab(value: string | null | undefined): number {
const index = SETTINGS_TABS.findIndex((t) => t.id === value);
return index < 0 ? 0 : index;
}
/** The last Settings tab this viewer used. A per-viewer convenience only. */
export function loadSettingsTab(): number {
try {
return parseSettingsTab(localStorage.getItem(KEY));
} catch {
return 0;
}
}
export function saveSettingsTab(index: number): void {
try {
const tab = SETTINGS_TABS[index];
if (tab) localStorage.setItem(KEY, tab.id);
} catch {
// Not remembering is harmless.
}
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { AUTO_DISMISS_MS, MAX_TOASTS, createToastStore } from "./toastStore";
function fakeTimers() {
let next = 1;
const pending = new Map<number, { fn: () => void; ms: number }>();
return {
setTimer: (fn: () => void, ms: number) => {
const id = next++;
pending.set(id, { fn, ms });
return id;
},
clearTimer: (id: unknown) => void pending.delete(id as number),
fireAll: () => [...pending.entries()].forEach(([id, t]) => (pending.delete(id), t.fn())),
pending,
};
}
describe("toast store", () => {
it("auto-dismisses non-error toasts after the delay", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
store.add("success", "Saved");
store.add("info", "FYI");
expect([...timers.pending.values()].map((t) => t.ms)).toEqual([AUTO_DISMISS_MS, AUTO_DISMISS_MS]);
timers.fireAll();
expect(store.getSnapshot()).toHaveLength(0);
});
it("keeps errors until they are closed", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
const id = store.add("error", "Failed", "details");
expect(timers.pending.size).toBe(0);
timers.fireAll();
expect(store.getSnapshot()).toEqual([{ id, kind: "error", title: "Failed", subtitle: "details" }]);
store.dismiss(id);
expect(store.getSnapshot()).toHaveLength(0);
});
it("dismiss cancels the timer and is idempotent", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
const id = store.add("success", "x");
store.dismiss(id);
expect(timers.pending.size).toBe(0);
store.dismiss(id);
expect(store.getSnapshot()).toHaveLength(0);
});
it("notifies subscribers with a new snapshot reference per change", () => {
const store = createToastStore(fakeTimers());
let calls = 0;
const off = store.subscribe(() => calls++);
const before = store.getSnapshot();
store.add("info", "a");
expect(calls).toBe(1);
expect(store.getSnapshot()).not.toBe(before);
const same = store.getSnapshot();
store.dismiss(999);
expect(calls).toBe(1);
expect(store.getSnapshot()).toBe(same);
off();
store.add("info", "b");
expect(calls).toBe(1);
});
it("caps the stack and drops the oldest first, cancelling its timer", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
for (let i = 0; i < MAX_TOASTS + 2; i++) store.add("success", `t${i}`);
const titles = store.getSnapshot().map((t) => t.title);
expect(titles).toHaveLength(MAX_TOASTS);
expect(titles[0]).toBe("t2");
expect(timers.pending.size).toBe(MAX_TOASTS);
});
it("clear empties the stack and cancels timers", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
store.add("success", "a");
store.add("error", "b");
store.clear();
expect(store.getSnapshot()).toHaveLength(0);
expect(timers.pending.size).toBe(0);
});
});
+83
View File
@@ -0,0 +1,83 @@
export type ToastKind = "success" | "info" | "warning" | "error";
export interface Toast {
id: number;
kind: ToastKind;
title: string;
subtitle?: string;
}
/** Success, info and warning toasts leave by themselves; errors stay until closed. */
export const AUTO_DISMISS_MS = 5000;
/** Oldest toasts are dropped beyond this, so a failing loop cannot fill the screen. */
export const MAX_TOASTS = 5;
type TimerId = unknown;
export interface ToastStoreOptions {
setTimer?: (fn: () => void, ms: number) => TimerId;
clearTimer?: (id: TimerId) => void;
}
export interface ToastStore {
add: (kind: ToastKind, title: string, subtitle?: string) => number;
dismiss: (id: number) => void;
clear: () => void;
subscribe: (listener: () => void) => () => void;
getSnapshot: () => readonly Toast[];
}
/** A tiny external store (useSyncExternalStore-compatible); the timers are injectable for tests. */
export function createToastStore(options: ToastStoreOptions = {}): ToastStore {
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
const clearTimer = options.clearTimer ?? ((id) => clearTimeout(id as ReturnType<typeof setTimeout>));
let toasts: readonly Toast[] = [];
let nextId = 1;
const timers = new Map<number, TimerId>();
const listeners = new Set<() => void>();
const emit = () => listeners.forEach((l) => l());
const drop = (id: number) => {
const timer = timers.get(id);
if (timer !== undefined) {
clearTimer(timer);
timers.delete(id);
}
};
const dismiss = (id: number) => {
drop(id);
if (!toasts.some((t) => t.id === id)) return;
toasts = toasts.filter((t) => t.id !== id);
emit();
};
return {
add(kind, title, subtitle) {
const id = nextId++;
toasts = [...toasts, { id, kind, title, subtitle }];
while (toasts.length > MAX_TOASTS) {
drop(toasts[0].id);
toasts = toasts.slice(1);
}
if (kind !== "error") timers.set(id, setTimer(() => dismiss(id), AUTO_DISMISS_MS));
emit();
return id;
},
dismiss,
clear() {
timers.forEach((t) => clearTimer(t));
timers.clear();
toasts = [];
emit();
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
getSnapshot: () => toasts,
};
}
+79
View File
@@ -58,16 +58,80 @@ export interface InvoiceSeries {
nextInvoiceNumber: string;
}
export type GstCategory = "registered_regular" | "unregistered" | "composition" | "sez" | "overseas";
/** Informational only; the tax heads still derive from supplier state and place of supply. */
export const GST_CATEGORY_LABELS: Record<GstCategory, string> = {
registered_regular: "Registered (regular)",
unregistered: "Unregistered",
composition: "Composition",
sez: "SEZ",
overseas: "Overseas",
};
export interface Client {
id: number | null;
name: string;
/** The composed multi-line address; what invoices store and print. */
address: string;
gstin: string;
/** Doubles as the address state and the default place of supply. */
stateCode: string;
poNumber: string;
createdAt: string;
addressLine1: string;
addressLine2: string;
city: string;
pincode: string;
gstCategory: GstCategory;
/** Notes / payment terms text copied onto a new invoice when the client is picked. */
defaultNotes: string;
/** Null means the settings default. */
paymentTermsDays: number | null;
/** Read-only: how many invoices reference this client. */
invoiceCount?: number;
}
export const blankClient = (): Client => ({
id: null,
name: "",
address: "",
gstin: "",
stateCode: "",
poNumber: "",
createdAt: "",
addressLine1: "",
addressLine2: "",
city: "",
pincode: "",
gstCategory: "unregistered",
defaultNotes: "",
paymentTermsDays: null,
});
/** A saved line item. `ratePaise` is the amount for "fixed" and the per-unit rate for "rate". */
export interface ItemPreset {
id: number | null;
description: string;
hsnSac: string;
mode: LineMode;
unit: LineUnit;
ratePaise: number;
sortOrder: number;
createdAt: string;
}
export const blankPreset = (): ItemPreset => ({
id: null,
description: "",
hsnSac: "",
mode: "fixed",
unit: "unit",
ratePaise: 0,
sortOrder: 0,
createdAt: "",
});
export interface InvoiceItem {
id?: number | null;
description: string;
@@ -107,6 +171,9 @@ export interface InvoiceInput {
export type InvoiceStatus = "draft" | "issued" | "cancelled";
/** Derived by the backend from the payments and the due date; "none" for invoices that take no payments. */
export type PaymentStatus = "none" | "unpaid" | "partially_paid" | "paid" | "overdue";
/** An invoice as stored: all amounts are derived on the Rust side. */
export interface Invoice {
id: number;
@@ -150,6 +217,12 @@ export interface Invoice {
archivedFingerprint?: string | null;
archivedAt?: string | null;
items: InvoiceItem[];
/** Payment summary added by the backend (integer paise). Optional so PDF fixtures built by hand need not carry it. */
totalPaise?: number;
paidPaise?: number;
tdsPaise?: number;
balancePaise?: number;
paymentStatus?: PaymentStatus;
}
export interface DraftSummary {
@@ -168,6 +241,12 @@ export interface InvoiceSummary {
total: number;
status: InvoiceStatus;
createdAt: string;
poNumber: string;
totalPaise: number;
paidPaise: number;
tdsPaise: number;
balancePaise: number;
paymentStatus: PaymentStatus;
}
export const EMPTY_ITEM: InvoiceItem = {
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
import { clientGstinStateError, gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
// Same GSTINs as the Rust tests in src-tauri/src/gst.rs.
describe("GSTIN checksum", () => {
@@ -25,3 +25,19 @@ describe("GSTIN checksum", () => {
expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "27")).toBeUndefined();
});
});
describe("clientGstinStateError", () => {
it("allows empty and NA", () => {
expect(clientGstinStateError("", "29")).toBeUndefined();
expect(clientGstinStateError("NA", "29")).toBeUndefined();
});
it("checks shape and checksum", () => {
expect(clientGstinStateError("29ABCDE1234F1Z5", "29")).toMatch(/check digit/);
expect(clientGstinStateError("29ABC", "")).toMatch(/15-character/);
});
it("checks the state prefix only when a state is chosen", () => {
expect(clientGstinStateError("29ABCDE1234F1ZW", "29")).toBeUndefined();
expect(clientGstinStateError("29abcde1234f1zw", "")).toBeUndefined();
expect(clientGstinStateError("29ABCDE1234F1ZW", "27")).toMatch(/starts with 29/);
});
});
+15
View File
@@ -84,3 +84,18 @@ export function accountError(value: string): string | undefined {
export const isPan = (v: string) => PAN_RE.test(norm(v));
export const isIfsc = (v: string) => IFSC_RE.test(norm(v));
export const isGstin = (v: string) => GSTIN_RE.test(norm(v));
/**
* Client GSTIN rules: shape, check digit, and that its state prefix matches the client's state when both
* are given. Empty (or "NA") is valid; a client may be unregistered.
*/
export function clientGstinStateError(gstin: string, stateCode: string): string | undefined {
const g = norm(gstin);
if (!g || g === "NA") return undefined;
const base = gstinFullError(g);
if (base) return base;
if (stateCode && g.slice(0, 2) !== stateCode) {
return `GSTIN starts with ${g.slice(0, 2)}, but the state is ${stateCode}`;
}
return undefined;
}
+26
View File
@@ -2,6 +2,7 @@ import { create, type Font } from "fontkit";
import { FONT_MANIFEST } from "../fonts/manifest";
import { familySources, FONT_STACK, stack, type FamilySource } from "../fonts/stack";
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
import { userFamilySources } from "../fonts/userFontStore";
export interface MeasureInput {
text: string;
@@ -29,6 +30,29 @@ export interface Measurer {
missing(text: string, face?: string): number[];
}
const userFonts = new Map<string, Font>();
/**
* The fontkit font of a user-imported family at a weight and style (the file react-pdf resolves for it), or
* undefined when `familyName` is not a user family. Parsed once per file and shared by every measurer.
*/
export function userFontkitFont(familyName: string, weight: number, italic: boolean): Font | null | undefined {
const sources = userFamilySources(familyName);
if (sources.length === 0) return undefined;
const src = resolveSource(
sources.map((s) => ({ file: s.id, fontWeight: s.fontWeight, fontStyle: s.fontStyle })),
weight,
italic ? "italic" : "normal",
);
if (!src) return null;
let font = userFonts.get(src.file);
if (!font) {
font = create((sources.find((s) => s.id === src.file) as { bytes: Uint8Array }).bytes) as Font;
userFonts.set(src.file, font);
}
return font;
}
/** react-pdf's FontFamily.resolve, so the measurer reads the same file the renderer embeds. */
function resolveSource(sources: FamilySource[], weight: number, style: "normal" | "italic"): FamilySource | undefined {
const styled = sources.filter((s) => s.fontStyle === style);
@@ -57,6 +81,8 @@ export function createMeasurer(loadBytes: (file: string) => Uint8Array): Measure
const fonts = new Map<string, Font>();
function load(familyName: string, weight: number, italic: boolean): Font | null {
const own = userFontkitFont(familyName, weight, italic);
if (own !== undefined) return own;
const fam = FONT_MANIFEST.families.find((f) => f.family === familyName);
if (!fam) return null;
const src = resolveSource(familySources(fam), weight, italic ? "italic" : "normal");
+8
View File
@@ -1,4 +1,5 @@
import { FONT_MANIFEST, type FontFileEntry } from "../fonts/manifest";
import { userFontkitFont } from "./measure";
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
function metricsFile(family: string, weight: number, italic: boolean): FontFileEntry | undefined {
@@ -23,6 +24,13 @@ function metricsFile(family: string, weight: number, italic: boolean): FontFileE
export function chromeHeight(role: RoleName | TypeToken, opts: { weight?: number; italic?: boolean; faceOverrides?: Readonly<Record<string, string>> } = {}): number {
const token = roleToken(role);
const family = resolveFace(token.face, opts.faceOverrides);
// A user-imported font has no manifest entry; its metrics come from the font itself (hhea, as the manifest's do).
const own = userFontkitFont(family, opts.weight ?? token.weight, opts.italic ?? token.italic ?? false);
if (own) {
// fontkit's types omit the hhea metrics it exposes.
const { ascent, descent, lineGap } = own as unknown as { ascent: number; descent: number; lineGap: number };
return Math.ceil(((ascent - descent + lineGap) / own.unitsPerEm) * token.size) + 1;
}
const file = metricsFile(family, opts.weight ?? token.weight, opts.italic ?? token.italic ?? false);
if (!file) throw new Error(`chromeHeight: no font metrics for "${family}"`);
// The manifest stores descent as a negative number, as the font's hhea table does.
+97 -12
View File
@@ -1,6 +1,15 @@
import { Font } from "@react-pdf/renderer";
import { FONT_MANIFEST } from "./manifest";
import { familySources } from "./stack";
import {
assertSfnt,
familyNameFor,
isUserFamilyKnown,
rememberUserFiles,
userFamilySources,
type UserFace,
type UserFontFile,
} from "./userFontStore";
export { FONT_STACK, familySources, stack, type FamilySource } from "./stack";
@@ -71,7 +80,9 @@ function primeComponents(font: FkFont): void {
for (const cp of lowest.values()) font.glyphForCodePoint(cp);
}
let primed: Promise<void> | null = null;
/** Descriptors already primed; a font registered after the first render (a user font) is primed on the next call. */
const primedKeys = new Set<string>();
let primeChain: Promise<void> = Promise.resolve();
/**
* Loads every registered face and gives the component glyphs of its composites their code points. fontkit
@@ -79,28 +90,102 @@ let primed: Promise<void> | null = null;
* time with no code points: Poppins' colon contains the period, so a document that sets ':' before any '.'
* would leave the period glyph with codePoints [] for every later document (amounts extracted as "1,200;00",
* textkit's line breaks slipping). Creating those glyphs here first makes a render independent of the renders
* before it. Memoised; await it before the first render.
* before it. Covers the bundled families and any user family registered so far, each descriptor once, so a
* font registered later is not skipped. Await it before the first render.
*/
export function primeFontGlyphs(): Promise<void> {
if (!registered) return Promise.resolve();
if (!primed) {
primed = run().catch((err) => {
primed = null;
throw err;
});
}
return primed;
if (!registered && primedKeys.size === 0 && userFamilies.length === 0) return Promise.resolve();
const next = primeChain.then(run);
primeChain = next.catch(() => undefined);
return next;
async function run(): Promise<void> {
const descriptors: Array<{ fontFamily: string; fontWeight: number; fontStyle: "normal" | "italic" }> = [];
if (registered) {
for (const fam of FONT_MANIFEST.families) {
for (const s of familySources(fam)) {
const descriptor = { fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle };
for (const s of familySources(fam)) descriptors.push({ fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle });
}
}
for (const family of userFamilies) {
for (const s of userFamilySources(family)) descriptors.push({ fontFamily: family, fontWeight: s.fontWeight, fontStyle: s.fontStyle });
}
for (const descriptor of descriptors) {
const key = `${descriptor.fontFamily}|${descriptor.fontWeight}|${descriptor.fontStyle}`;
if (primedKeys.has(key)) continue;
await Font.load(descriptor);
const data = (Font as unknown as { getFont(d: object): { data: FkFont | null } }).getFont(descriptor).data;
if (data) primeComponents(data);
primedKeys.add(key);
}
}
}
// ---- User-imported fonts (Now, Gotham, Open Sauce One) ----
const userFamilies: string[] = [];
const isNode = typeof process !== "undefined" && !!process.versions?.node;
/**
* A src react-pdf can load the bytes from. In a browser or worker that is a blob: URL (react-pdf fetches it;
* a data URI would be decoded character by character, which is slow for a multi-MB font). Under Node it is a
* temporary file, because react-pdf's Node build opens anything that is not an http(s) URL as a path.
*/
async function sourceFor(bytes: Uint8Array, id: string): Promise<string> {
if (!isNode) return URL.createObjectURL(new Blob([bytes as BlobPart], { type: "font/ttf" }));
const nodeFs = "node:fs";
const nodeOs = "node:os";
const nodePath = "node:path";
const [fs, os, path] = await Promise.all([import(/* @vite-ignore */ nodeFs), import(/* @vite-ignore */ nodeOs), import(/* @vite-ignore */ nodePath)]);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "voiced-userfont-"));
const file = path.join(dir, `${id.slice(0, 16)}.ttf`);
fs.writeFileSync(file, bytes);
return file;
}
/**
* Registers one face's files as the family `user:<Face>:<set id>` and returns that name. Every file's magic bytes
* are checked before fontkit sees it. Each weight without an italic gets its upright file as the italic. Idempotent:
* the same set registers once. Never resets the font store; a different set is a different family.
*/
export async function registerUserFontSet(face: UserFace, files: UserFontFile[]): Promise<string> {
if (files.length === 0) throw new Error(`No font files were given for ${face}`);
for (const f of files) assertSfnt(f.bytes);
const family = familyNameFor(face, files.map((f) => f.sha256));
if (isUserFamilyKnown(family)) return family;
rememberUserFiles(family, files);
const srcs = new Map<string, string>();
for (const s of userFamilySources(family)) {
if (!srcs.has(s.id)) srcs.set(s.id, await sourceFor(s.bytes, s.id));
}
Font.register({
family,
fonts: userFamilySources(family).map((s) => ({ src: srcs.get(s.id) as string, fontWeight: s.fontWeight as never, fontStyle: s.fontStyle })),
});
userFamilies.push(family);
await primeFontGlyphs();
return family;
}
/** Cheap non-cryptographic id (two FNV-1a passes, 16 hex digits) for callers that have no sha256, i.e. tests. */
function fallbackId(bytes: Uint8Array): string {
let a = 0x811c9dc5;
let b = 0x01000193;
for (let i = 0; i < bytes.length; i++) {
a = Math.imul(a ^ bytes[i], 0x01000193) >>> 0;
b = Math.imul(b ^ bytes[i], 0x811c9dc5) >>> 0;
}
return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0");
}
/** One-file convenience for Node tests and single-weight imports: the font as the only member of the face's set. */
export async function registerUserFont(
face: UserFace,
bytes: Uint8Array,
opts: { weight?: number; italic?: boolean; sha256?: string } = {},
): Promise<{ family: string; sha256: string }> {
const sha256 = opts.sha256 ?? fallbackId(bytes);
const family = await registerUserFontSet(face, [{ face, weight: opts.weight ?? 400, italic: opts.italic ?? false, sha256, bytes }]);
return { family, sha256 };
}
const MIN_FONT_BYTES = 10 * 1024;
+55 -3
View File
@@ -35,6 +35,13 @@ export interface TypeToken {
tnum?: boolean;
/** Smallest size IdText/FitText may shrink to. */
minSize?: number;
/**
* Size and tracking to use instead of `size`/`tracking` when a user-imported font replaces this token's
* substitute face. The token's own values are tuned for the substitute (Gotham drawn in Montserrat, Now in
* Jost); the real font takes the design values. defineRoles turns a token that has this into one whose
* size/tracking follow the active overrides; without an override its values are exactly the literals.
*/
ownFont?: { size?: number; tracking?: number };
}
const SANS: FaceKey = "IBM Plex Sans";
@@ -63,14 +70,37 @@ export type RoleTable<K extends string = string> = Record<K, TypeToken>;
/** Checks a role table against TypeToken and keeps its literal keys: `export const ROLES = defineRoles({ ... })`. */
export function defineRoles<const T extends Record<string, TypeToken>>(table: T): T {
return table;
const out: Record<string, TypeToken> = {};
for (const [name, token] of Object.entries(table)) out[name] = token.ownFont ? followOverrides(token) : token;
return out as T;
}
/**
* A copy of `token` whose size and tracking read as `ownFont.*` while a user font overrides its face, and as
* the original literals otherwise. Getters, so every plan and layout that reads `token.size` sees the right
* value without threading anything through; only the keys `ownFont` names become accessors.
*/
function followOverrides(token: TypeToken): TypeToken {
const own = token.ownFont as NonNullable<TypeToken["ownFont"]>;
const copy: TypeToken = { ...token };
for (const key of ["size", "tracking"] as const) {
const design = own[key];
if (design === undefined) continue;
const substituted = token[key];
Object.defineProperty(copy, key, {
enumerable: true,
configurable: true,
get: () => (resolveFace(token.face).startsWith(USER_FAMILY_PREFIX) ? design : substituted),
});
}
return copy;
}
/**
* Licensed template faces (Now, Gotham, Open Sauce One, ...) map to the OFL look-alike that is
* actually bundled. A user-imported font replaces the licensed name through `faceOverrides`.
* Gotham runs about 4% narrower than Montserrat, so tokens that use it carry sizes already scaled by
* GOTHAM_SIZE (see templates/northline); a real Gotham import would need them back at 1.
* GOTHAM_SIZE (see templates/northline); a real Gotham import takes them back at 1 through TypeToken.ownFont.
*/
export const SUBSTITUTES: Readonly<Record<string, string>> = {
Now: "Jost",
@@ -78,8 +108,30 @@ export const SUBSTITUTES: Readonly<Record<string, string>> = {
"Open Sauce One": "Poppins",
};
/** Family names of user-imported fonts start with this (`user:Gotham:<set id>`). */
export const USER_FAMILY_PREFIX = "user:";
type FaceOverrides = Readonly<Record<string, string>>;
let activeOverrides: FaceOverrides | undefined;
/**
* Runs `fn` with `overrides` as the default for every resolveFace call that is not given its own map. A render
* sets this for its whole duration so templates, plans and the measurer agree on the faces without each of them
* being handed the map. Renders are serialised (the worker's job lane, the main-thread client), so one slot is enough.
*/
export async function withFaceOverrides<T>(overrides: FaceOverrides | undefined, fn: () => Promise<T>): Promise<T> {
const previous = activeOverrides;
activeOverrides = overrides && Object.keys(overrides).length > 0 ? overrides : undefined;
try {
return await fn();
} finally {
activeOverrides = previous;
}
}
/** The family name to register/look up for a face: user override first, then substitute, then itself. */
export function resolveFace(face: string, overrides?: Readonly<Record<string, string>>): string {
export function resolveFace(face: string, overrides: FaceOverrides | undefined = activeOverrides): string {
return overrides?.[face] ?? SUBSTITUTES[face] ?? face;
}
+128
View File
@@ -0,0 +1,128 @@
/**
* The user-imported fonts that rendering knows about: Now, Gotham and Open Sauce One only. This module holds
* plain data (no react-pdf), so the measurer can read it too. Registration with react-pdf is in register.ts.
*
* A face's fonts form a "set" (one file per weight and style) and each set is one react-pdf family named
* `user:<Face>:<set id>`. The id comes from the files' hashes, so replacing a font gives a new family instead of
* appending to the old one (react-pdf's font store only appends, and Font.reset() must not be called).
*/
import { USER_FAMILY_PREFIX } from "./roles";
/** The commercial template faces a user may import. Overrides for any other family are out of scope. */
export const USER_FACES = ["Now", "Gotham", "Open Sauce One"] as const;
export type UserFace = (typeof USER_FACES)[number];
export const isUserFace = (face: unknown): face is UserFace => USER_FACES.includes(face as UserFace);
/** What a font file looks like by its first four bytes. */
export type FontFormat = "ttf" | "otf" | "woff" | "woff2" | "ttc" | "unknown";
export function sniffFontFormat(b: Uint8Array): FontFormat {
if (b.length < 4) return "unknown";
if (b[0] === 0 && b[1] === 1 && b[2] === 0 && b[3] === 0) return "ttf";
const tag = String.fromCharCode(b[0], b[1], b[2], b[3]);
switch (tag) {
case "true":
return "ttf";
case "OTTO":
return "otf";
case "wOFF":
return "woff";
case "wOF2":
return "woff2";
case "ttcf":
return "ttc";
default:
return "unknown";
}
}
const REFUSALS: Partial<Record<FontFormat, string>> = {
woff2: "WOFF2 fonts embed without glyph outlines (invisible text in the PDF). Use the TTF or OTF file.",
woff: "A WOFF file must be converted to TTF/OTF first (the importer does this).",
ttc: "Font collections (.ttc) are not supported. Use a single TTF or OTF file.",
unknown: "This is not a TrueType or OpenType font file.",
};
/** Throws unless the bytes are a plain TrueType or OpenType (sfnt) file; fontkit must never see anything else. */
export function assertSfnt(b: Uint8Array): "ttf" | "otf" {
const format = sniffFontFormat(b);
if (format === "ttf" || format === "otf") return format;
throw new Error(REFUSALS[format] ?? REFUSALS.unknown);
}
/** One imported font file, as the renderer needs it. `sha256` identifies it (hex). */
export interface UserFontFile {
face: UserFace;
weight: number;
italic: boolean;
sha256: string;
bytes: Uint8Array;
}
const filesById = new Map<string, UserFontFile>();
const familyFiles = new Map<string, UserFontFile[]>();
/** The family name of a face's set of files. Order does not matter. */
export function familyNameFor(face: UserFace, ids: readonly string[]): string {
return `${USER_FAMILY_PREFIX}${face}:${[...ids].sort().map((id) => id.slice(0, 8)).join("-")}`;
}
export const hasUserFont = (id: string): boolean => filesById.has(id);
export const getUserFontFile = (id: string): UserFontFile | undefined => filesById.get(id);
export function rememberUserFile(file: UserFontFile): void {
filesById.set(file.sha256, file);
}
export function rememberUserFiles(family: string, files: UserFontFile[]): void {
for (const f of files) filesById.set(f.sha256, f);
familyFiles.set(family, files);
}
export const isUserFamilyKnown = (family: string): boolean => familyFiles.has(family);
export interface UserFamilySource {
id: string;
fontWeight: number;
fontStyle: "normal" | "italic";
bytes: Uint8Array;
}
/**
* The sources of a user family: its files, plus the upright file of each weight reused as italic when that weight
* has no italic of its own (every registered family needs an italic, layout rule 8). Same rule as familySources.
*/
export function userFamilySources(family: string): UserFamilySource[] {
const files = familyFiles.get(family);
if (!files) return [];
const sources: UserFamilySource[] = files.map((f) => ({
id: f.sha256,
fontWeight: f.weight,
fontStyle: f.italic ? "italic" : "normal",
bytes: f.bytes,
}));
for (const weight of new Set(sources.map((s) => s.fontWeight))) {
if (sources.some((s) => s.fontWeight === weight && s.fontStyle === "italic")) continue;
const upright = sources.find((s) => s.fontWeight === weight && s.fontStyle === "normal");
if (upright) sources.push({ ...upright, fontStyle: "italic" });
}
return sources;
}
/** `prefs.fonts` (face -> ids of the files in use) as the face -> family map resolveFace takes. */
export function faceOverridesFor(fonts: Partial<Record<UserFace, string[]>> | undefined): Record<string, string> | undefined {
if (!fonts) return undefined;
const out: Record<string, string> = {};
for (const face of USER_FACES) {
const ids = fonts[face];
if (!ids || ids.length === 0) continue;
const missing = ids.filter((id) => !filesById.has(id));
if (missing.length > 0) {
throw new Error(`The font imported for ${face} is not loaded in the renderer (${missing.length} file(s) missing).`);
}
const family = familyNameFor(face, ids);
if (!familyFiles.has(family)) throw new Error(`The font set for ${face} was not registered as a family.`);
out[face] = family;
}
return Object.keys(out).length > 0 ? out : undefined;
}
+160
View File
@@ -0,0 +1,160 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { createElement as h } from "react";
import { Document, Page, renderToBuffer, Text } from "@react-pdf/renderer";
import { describe, expect, it } from "vitest";
import { createMeasurer } from "../engine/measure";
import { DEFAULT_PREFS, parsePrefs } from "../model/prefs";
import { registerUserFont, registerUserFontSet } from "./register";
import { resolveFace, withFaceOverrides } from "./roles";
import { assertSfnt, faceOverridesFor, sniffFontFormat } from "./userFontStore";
import { CITRUS_ROLES } from "../templates/northline/citrus";
import { PURPLE_ROLES } from "../templates/northline/purple";
import { MARBLE_ROLES } from "../templates/marble/tokens";
import { TEMPLATE_CATALOG } from "../templates/catalog";
import { TEMPLATES } from "../templates/registry";
import { ensureSetup, prefsFor, renderFixture } from "../templates/harness";
import { gotham } from "../templates/northline/variant";
import { FONTS_DIR } from "../testing/setup";
ensureSetup();
const font = (folder: string, file: string) => new Uint8Array(readFileSync(path.join(FONTS_DIR, folder, file)));
const latin1 = (b: Uint8Array) => Buffer.from(b).toString("latin1");
const SPEC = { name: "user-font", items: 3, logo: "none" as const };
describe("font sniffing and refusal", () => {
it("recognises sfnt, WOFF, WOFF2 and collections by their magic bytes", () => {
const tag = (s: string) => new Uint8Array([...s].map((c) => c.charCodeAt(0)).concat([0, 0, 0, 0]));
expect(sniffFontFormat(new Uint8Array([0, 1, 0, 0, 0, 0]))).toBe("ttf");
expect(sniffFontFormat(tag("true"))).toBe("ttf");
expect(sniffFontFormat(tag("OTTO"))).toBe("otf");
expect(sniffFontFormat(tag("wOFF"))).toBe("woff");
expect(sniffFontFormat(tag("wOF2"))).toBe("woff2");
expect(sniffFontFormat(tag("ttcf"))).toBe("ttc");
expect(sniffFontFormat(tag("<htm"))).toBe("unknown");
expect(sniffFontFormat(new Uint8Array([0, 1]))).toBe("unknown");
});
it("refuses WOFF2, WOFF, collections and non-fonts before fontkit sees them", async () => {
const jost = font("jost", "Jost-Regular.ttf");
expect(assertSfnt(jost)).toBe("ttf");
const patched = (magic: string) => {
const b = jost.slice();
b.set([...magic].map((c) => c.charCodeAt(0)));
return b;
};
expect(() => assertSfnt(patched("wOF2"))).toThrow(/WOFF2/);
expect(() => assertSfnt(patched("ttcf"))).toThrow(/collections/);
expect(() => assertSfnt(patched("wOFF"))).toThrow(/WOFF/);
expect(() => assertSfnt(new TextEncoder().encode("<!doctype html>"))).toThrow(/not a TrueType/);
await expect(registerUserFont("Gotham", patched("wOF2"))).rejects.toThrow(/WOFF2/);
});
});
describe("prefs carry user fonts", () => {
it("keeps valid face/hash lists and drops everything else", () => {
const sha = "a".repeat(64);
const prefs = parsePrefs(JSON.stringify({ version: 1, templateId: "marble", fonts: { Now: [sha, "zz", 5], Gotham: [], Poppins: [sha], "Open Sauce One": "nope" } }));
expect(prefs.fonts).toEqual({ Now: [sha] });
expect(parsePrefs(JSON.stringify({ version: 1 })).fonts).toBeUndefined();
expect(parsePrefs("{}")).toEqual(DEFAULT_PREFS);
});
});
describe("override compensation", () => {
it("leaves the tuned values alone without an override", () => {
expect(PURPLE_ROLES.label.size).toBe(gotham(12));
expect(CITRUS_ROLES.tableBody.size).toBe(gotham(12));
expect(MARBLE_ROLES.body.tracking).toBe(0.105);
expect(Object.keys(MARBLE_ROLES.body)).toContain("tracking");
});
it("takes the design size and tracking back only while a user font replaces the face", async () => {
await withFaceOverrides({ Gotham: "user:Gotham:x", Now: "user:Now:x" }, async () => {
expect(PURPLE_ROLES.label.size).toBe(12);
expect(CITRUS_ROLES.tableBody.size).toBe(12);
expect(MARBLE_ROLES.body.tracking).toBe(0.04);
expect({ ...MARBLE_ROLES.body }.tracking).toBe(0.04);
// A face that is not overridden keeps its tuned values (Open Sauce One stays Poppins).
expect(PURPLE_ROLES.wordmark.size).toBe(94.07);
});
await withFaceOverrides({ Now: "user:Now:x" }, async () => {
expect(PURPLE_ROLES.label.size).toBe(gotham(12));
expect(MARBLE_ROLES.body.tracking).toBe(0.04);
});
expect(PURPLE_ROLES.label.size).toBe(gotham(12));
expect(MARBLE_ROLES.body.tracking).toBe(0.105);
expect(resolveFace("Gotham")).toBe("Montserrat");
});
it("the catalog lists exactly the licensed faces each template's roles use", () => {
for (const entry of TEMPLATE_CATALOG) {
const used = new Set(Object.values(TEMPLATES[entry.id].roles).map((t) => t.face).filter((f) => f === "Now" || f === "Gotham" || f === "Open Sauce One"));
expect([...(entry.faces ?? [])].sort(), entry.id).toEqual([...used].sort());
}
});
});
describe("a user font replaces a template face", () => {
it("embeds the imported font in a Purple Pop invoice, and only then", async () => {
const lustria = font("lustria", "Lustria-Regular.ttf");
const plain = await renderFixture(SPEC, prefsFor("A4", { templateId: "purple-pop" }));
expect(latin1(plain.result.bytes)).not.toContain("Lustria");
expect(plain.result.bytes.length).toBeGreaterThan(1000);
const { family, sha256 } = await registerUserFont("Gotham", lustria);
expect(family).toMatch(/^user:Gotham:[0-9a-f]{8}$/);
const over = await renderFixture(SPEC, prefsFor("A4", { templateId: "purple-pop", fonts: { Gotham: [sha256] } }));
expect(latin1(over.result.bytes)).toContain("Lustria");
expect(over.result.fingerprint).not.toBe(plain.result.fingerprint);
expect(over.result.issues.filter((i) => i.severity === "error")).toEqual([]);
// The override ended with the render: the same prefs without fonts give the original fingerprint again.
const after = await renderFixture(SPEC, prefsFor("A4", { templateId: "purple-pop" }));
expect(after.result.fingerprint).toBe(plain.result.fingerprint);
}, 60_000);
it("a Marble invoice with Now replaced by a user font lays out and embeds it", async () => {
const { sha256 } = await registerUserFont("Now", font("dm-sans", "DMSans-Regular.ttf"));
const plain = await renderFixture(SPEC, prefsFor("A4", { templateId: "marble" }));
const over = await renderFixture(SPEC, prefsFor("A4", { templateId: "marble", fonts: { Now: [sha256] } }));
expect(latin1(over.result.bytes)).toContain("DMSans");
expect(latin1(plain.result.bytes)).not.toContain("DMSans");
expect(over.pdf.pages[0].items.map((i) => i.str).join(" ")).toContain("₹");
}, 60_000);
it("fails clearly when prefs name a font the renderer was never given", async () => {
const prefs = prefsFor("A4", { templateId: "purple-pop", fonts: { Gotham: ["f".repeat(64)] } });
await expect(renderFixture(SPEC, prefs)).rejects.toThrow(/not loaded/);
});
it("every weight gets an italic: a set registers its upright files as italics, and italic text does not throw", async () => {
const regular = font("montserrat", "Montserrat-Regular.ttf");
const bold = font("montserrat", "Montserrat-Bold.ttf");
const family = await registerUserFontSet("Open Sauce One", [
{ face: "Open Sauce One", weight: 400, italic: false, sha256: "1".repeat(16), bytes: regular },
{ face: "Open Sauce One", weight: 700, italic: false, sha256: "2".repeat(16), bytes: bold },
]);
const render = (fontWeight: number) =>
renderToBuffer(h(Document, null, h(Page, { size: "A4" }, h(Text, { style: { fontFamily: [family], fontStyle: "italic", fontWeight } }, "Total ₹1,200.00"))));
for (const w of [400, 700]) expect((await render(w)).subarray(0, 5).toString()).toBe("%PDF-");
// Registering the same set again is a no-op.
expect(await registerUserFontSet("Open Sauce One", [
{ face: "Open Sauce One", weight: 700, italic: false, sha256: "2".repeat(16), bytes: bold },
{ face: "Open Sauce One", weight: 400, italic: false, sha256: "1".repeat(16), bytes: regular },
])).toBe(family);
expect(faceOverridesFor({ "Open Sauce One": ["1".repeat(16), "2".repeat(16)] })).toEqual({ "Open Sauce One": family });
});
it("the measurer reads user families from memory, bold and italic included", async () => {
const m = createMeasurer((file) => new Uint8Array(readFileSync(path.join(FONTS_DIR, file))));
const poppins = font("poppins", "Poppins-Regular.ttf");
const { family } = await registerUserFont("Gotham", poppins, { weight: 400 });
const own = m.measureWidth({ text: "Consultation", face: family, weight: 400, size: 12 });
const plex = m.measureWidth({ text: "Consultation", face: "Poppins", weight: 400, size: 12 });
expect(own).toBeCloseTo(plex, 6);
expect(m.measureWidth({ text: "Consultation", face: family, weight: 700, italic: true, size: 12 })).toBeCloseTo(plex, 6);
expect(m.missing("₹", family)).toEqual([]);
});
});
+17
View File
@@ -1,4 +1,5 @@
import type { MarginPreset, PageId } from "../engine/geometry";
import { isUserFace, type UserFace } from "../fonts/userFontStore";
/** Page and template choices frozen with an invoice at issue (stored as JSON). */
export interface RenderPrefsV1 {
@@ -12,6 +13,11 @@ export interface RenderPrefsV1 {
/** Templates without a reference logo placeholder (Linea, ...) show the logo only when this is true. */
show?: boolean;
};
/**
* User-imported fonts in use, per face (Now, Gotham, Open Sauce One): the sha256 of each file (one per weight/style).
* Frozen with the invoice, which is what keeps those font files from being deleted. Absent: the bundled substitutes.
*/
fonts?: Partial<Record<UserFace, string[]>>;
}
export const DEFAULT_PREFS: RenderPrefsV1 = {
@@ -22,6 +28,7 @@ export const DEFAULT_PREFS: RenderPrefsV1 = {
};
const PAGE_SIZE_IDS: readonly PageId[] = ["A4", "LETTER"];
const FONT_ID = /^[0-9a-f]{16,64}$/;
const MARGIN_IDS: readonly MarginPreset[] = ["template", "narrow", "normal"];
/** Tolerant: anything unreadable or out of range falls back to the default for that field. */
@@ -49,6 +56,16 @@ export function parsePrefs(json: string | null | undefined): RenderPrefsV1 {
if (typeof show === "boolean") out.show = show;
if (Object.keys(out).length > 0) prefs.logo = out;
}
const fonts = o.fonts;
if (typeof fonts === "object" && fonts !== null) {
const out: NonNullable<RenderPrefsV1["fonts"]> = {};
for (const [face, ids] of Object.entries(fonts as Record<string, unknown>)) {
if (!isUserFace(face) || !Array.isArray(ids)) continue;
const valid = ids.filter((id): id is string => typeof id === "string" && FONT_ID.test(id)).slice(0, 16);
if (valid.length > 0) out[face] = valid;
}
if (Object.keys(out).length > 0) prefs.fonts = out;
}
return prefs;
}
+92 -1
View File
@@ -16,7 +16,7 @@ class FakeWorker implements WorkerLike {
onerror: WorkerLike["onerror"] = null;
sent: WorkerRequest[] = [];
terminated = false;
postMessage(message: WorkerRequest) {
postMessage(message: WorkerRequest, _transfer?: Transferable[]) {
this.sent.push(message);
}
terminate() {
@@ -378,3 +378,94 @@ describe("main-thread client and fallback", () => {
expect((await client.render(job(8))).id).toBe(8);
});
});
describe("user fonts in render jobs", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
const file = (sha: string, bytes: number[]) => ({ face: "Gotham" as const, weight: 400, italic: false, sha256: sha, bytes: new Uint8Array(bytes) });
const withFonts = (id: number, files: ReturnType<typeof file>[]): RenderJob => ({
id,
model: {} as RenderJob["model"],
prefs: { fonts: { Gotham: files.map((f) => f.sha256) } } as unknown as RenderJob["prefs"],
userFonts: files,
});
it("sends each font to a worker once, as transferred copies, and again to a replacement worker", async () => {
const posts: Array<{ job: RenderJob; transfer: Transferable[] | undefined }> = [];
const workers: FakeWorker[] = [];
const client = createRenderClient({
timeoutMs: 1000,
workerFactory: () => {
const w = new FakeWorker();
w.postMessage = (message: WorkerRequest, transfer?: Transferable[]) => {
posts.push({ job: (message as { job: RenderJob }).job, transfer });
w.sent.push(message);
};
workers.push(w);
return w;
},
});
const master = file("a".repeat(16), [1, 2, 3]);
const p1 = client.render(withFonts(1, [master]));
workers[0].ready();
expect(posts[0].job.userFonts?.map((f) => f.sha256)).toEqual([master.sha256]);
expect(posts[0].job.userFonts?.[0].bytes).not.toBe(master.bytes);
expect(posts[0].transfer).toHaveLength(1);
expect(Array.from(master.bytes)).toEqual([1, 2, 3]);
workers[0].reply(1);
await p1;
const extra = file("b".repeat(16), [9]);
const p2 = client.render(withFonts(2, [master, extra]));
expect(posts[1].job.userFonts?.map((f) => f.sha256)).toEqual([extra.sha256]);
workers[0].reply(2);
await p2;
// A worker replaced after a timeout starts with nothing, so every font goes again.
const p3 = client.render(withFonts(3, [master]));
workers[0].reply(3);
await p3;
const p4 = client.render(withFonts(4, [master, extra]));
const timedOut = settled(p4);
vi.advanceTimersByTime(1000);
expect(await timedOut).toBeInstanceOf(RenderTimeout);
expect(workers).toHaveLength(2);
const p5 = client.render(withFonts(5, [master, extra]));
workers[1].ready();
expect(posts[posts.length - 1].job.userFonts?.map((f) => f.sha256).sort()).toEqual([master.sha256, extra.sha256]);
workers[1].reply(5);
await p5;
});
it("the auto client attaches the bytes from the loader and reads each file once", async () => {
vi.useRealTimers();
const reads: string[] = [];
const workers: FakeWorker[] = [];
const client = createAutoClient({
workerFactory: () => {
const w = new FakeWorker();
workers.push(w);
return w;
},
loadUserFont: async (sha) => {
reads.push(sha);
return file(sha, [7]);
},
});
const prefs = { fonts: { Gotham: ["c".repeat(16)] } } as unknown as RenderJob["prefs"];
const p1 = client.render({ id: 1, model: {} as RenderJob["model"], prefs });
await vi.waitFor(() => expect(workers).toHaveLength(1));
workers[0].ready();
await vi.waitFor(() => expect(workers[0].sent).toHaveLength(1));
expect((workers[0].sent[0] as { job: RenderJob }).job.userFonts?.map((f) => f.sha256)).toEqual(["c".repeat(16)]);
workers[0].reply(1);
await p1;
const p2 = client.render({ id: 2, model: {} as RenderJob["model"], prefs });
await vi.waitFor(() => expect(workers[0].sent).toHaveLength(2));
expect((workers[0].sent[1] as { job: RenderJob }).job.userFonts).toEqual([]);
workers[0].reply(2);
await p2;
expect(reads).toEqual(["c".repeat(16)]);
});
});
+60 -6
View File
@@ -1,3 +1,4 @@
import type { UserFontFile } from "../fonts/userFontStore";
import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
/** A newer render call replaced this one before it finished (or before it started). */
@@ -35,7 +36,7 @@ export class RenderFailed extends Error {
/** The part of Worker the client uses, so tests can supply a fake. */
export interface WorkerLike {
postMessage(message: WorkerRequest): void;
postMessage(message: WorkerRequest, transfer?: Transferable[]): void;
terminate(): void;
onmessage: ((event: { data: WorkerMessage }) => void) | null;
onerror: ((event: { message?: string }) => void) | null;
@@ -55,6 +56,11 @@ export interface RenderClient {
}
export interface RenderClientOptions {
/**
* Returns a user font file by its sha256 (the app reads it from the database). Needed only for jobs whose
* prefs name user fonts; the result is cached, so each file is read once per session.
*/
loadUserFont?: (sha256: string) => Promise<UserFontFile>;
workerFactory?: () => WorkerLike;
/** Per-job watchdog. react-pdf's paginate loop is synchronous, so a layout bug hangs the worker for good. */
timeoutMs?: number;
@@ -85,8 +91,21 @@ type Running = Slot & {
timer: ReturnType<typeof setTimeout>;
};
const requestFor = (slot: Slot): WorkerRequest =>
slot.kind === "render" ? { type: "render", job: slot.job } : { type: "assemble-images", job: slot.job };
/**
* The message for a slot plus what to transfer. A render's user fonts are cut down to those this worker has not
* been sent (`sent`), and each goes as a transferred copy: the caller's bytes are a master copy that must survive
* a worker restart, and a transferred buffer is detached on this side.
*/
function requestFor(slot: Slot, sent: Set<string>): { request: WorkerRequest; transfer: Transferable[]; ids: string[] } {
if (slot.kind !== "render") return { request: { type: "assemble-images", job: slot.job }, transfer: [], ids: [] };
const fresh = (slot.job.userFonts ?? []).filter((f) => !sent.has(f.sha256));
const copies = fresh.map((f) => ({ ...f, bytes: f.bytes.slice() }));
return {
request: { type: "render", job: { ...slot.job, userFonts: copies } },
transfer: copies.map((f) => f.bytes.buffer),
ids: fresh.map((f) => f.sha256),
};
}
/**
* Renders in a module worker. Only the newest render matters: a new call rejects the render in flight
@@ -105,11 +124,14 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
let running: Running | null = null;
let pending: RenderSlot | null = null;
const assembles: AssembleSlot[] = [];
/** Ids of the user font files the current worker has been sent; a new worker starts with none. */
const sent = new Set<string>();
const stopWorker = () => {
const w = worker;
worker = null;
ready = false;
sent.clear();
if (w) {
w.onmessage = null;
w.onerror = null;
@@ -147,7 +169,9 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
}
if (!slot) return;
running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) };
worker.postMessage(requestFor(slot));
const { request, transfer, ids } = requestFor(slot, sent);
worker.postMessage(request, transfer);
for (const id of ids) sent.add(id);
};
const spawn = () => {
@@ -239,8 +263,13 @@ export type MainExecutor = (job: RenderJob) => Promise<RenderResult>;
// Loaded on demand so react-pdf stays out of the main bundle unless the worker is unavailable.
const runOnMainThread: MainExecutor = async (job) => {
const [{ initBrowserRendering }, { renderInvoicePdf }] = await Promise.all([import("./browserInit"), import("./invoice")]);
const [{ initBrowserRendering }, { renderInvoicePdf }, { registerJobFonts }] = await Promise.all([
import("./browserInit"),
import("./invoice"),
import("./userFontJob"),
]);
await initBrowserRendering();
await registerJobFonts(job.prefs, job.userFonts);
// Let pending input and paint run before the long synchronous layout.
await new Promise<void>((r) => setTimeout(r, 0));
const t0 = performance.now();
@@ -337,6 +366,27 @@ export function createAutoClient(
): RenderClient {
const primary = createRenderClient(opts);
let main: RenderClient | null = null;
const fontCache = new Map<string, Promise<UserFontFile>>();
let renderCalls = 0;
/** Attaches the bytes of the user fonts the job's prefs name (read once, then cached). */
const withFonts = async (job: RenderJob): Promise<RenderJob> => {
const ids = Object.values(job.prefs.fonts ?? {}).flat();
if (ids.length === 0) return job;
const load = opts.loadUserFont;
if (!load) throw new Error("This render needs user fonts but no font loader is configured");
const files = await Promise.all(
ids.map((id) => {
let p = fontCache.get(id);
if (!p) {
p = load(id);
fontCache.set(id, p);
p.catch(() => fontCache.delete(id));
}
return p;
}),
);
return { ...job, userFonts: files };
};
const fallback = () => {
main ??= createMainThreadClient(opts.mainExecutor, opts.mainAssembleExecutor);
primary.dispose();
@@ -346,7 +396,11 @@ export function createAutoClient(
get mode() {
return main ? "main" : "worker";
},
render(job) {
async render(plain) {
const call = ++renderCalls;
const job = await withFonts(plain);
// Reading fonts took time; keep latest-wins: a render requested meanwhile replaces this one.
if (call !== renderCalls) throw new Superseded();
if (main) return main.render(job);
return primary.render(job).catch((err) => {
if (!(err instanceof WorkerUnavailable)) throw err;
+8 -1
View File
@@ -4,6 +4,8 @@ import { computeFrame, PAGE_SIZES, type PageFrame } from "../engine/geometry";
import type { AuditIssue } from "../engine/audit";
import type { RenderModel } from "../model/build";
import type { RenderPrefsV1 } from "../model/prefs";
import { withFaceOverrides } from "../fonts/roles";
import { faceOverridesFor } from "../fonts/userFontStore";
import { CLOSING_ID } from "../engine/ids";
import { marginsFor, type PlacedLogo } from "../templates/contract";
import { getTemplate } from "../templates/registry";
@@ -41,7 +43,12 @@ export function frameForPrefs(prefs: RenderPrefsV1): PageFrame {
* model + prefs -> template, frame, logo placement and keep-outs -> InvoicePdf -> renderCore. Needs
* fonts registered and a measurer installed (render/browserInit.ts in the app, testing/setup.ts in tests).
*/
export async function renderInvoicePdf({ model, prefs }: RenderInvoiceInput, bytesOf?: RenderBytes): Promise<RenderInvoiceResult> {
export function renderInvoicePdf(input: RenderInvoiceInput, bytesOf?: RenderBytes): Promise<RenderInvoiceResult> {
// prefs.fonts names the user fonts (already registered by the caller) that replace the substitute faces.
return withFaceOverrides(faceOverridesFor(input.prefs.fonts), () => renderInvoicePdfWith(input, bytesOf));
}
async function renderInvoicePdfWith({ model, prefs }: RenderInvoiceInput, bytesOf?: RenderBytes): Promise<RenderInvoiceResult> {
const template = getTemplate(prefs.templateId);
const frame = frameForPrefs(prefs);
+6
View File
@@ -1,5 +1,6 @@
import type { AuditIssue } from "../engine/audit";
import type { RenderModel } from "../model/build";
import type { UserFontFile } from "../fonts/userFontStore";
import type { RenderPrefsV1 } from "../model/prefs";
// Everything here crosses postMessage, so it stays plain data: no functions, classes or Maps.
@@ -8,6 +9,11 @@ export interface RenderJob {
id: number;
model: RenderModel;
prefs: RenderPrefsV1;
/**
* Bytes of the user fonts `prefs.fonts` names. The caller attaches all of them; the worker client sends only the
* ones that worker does not hold yet (as transferred copies, so the caller's master copy stays usable).
*/
userFonts?: UserFontFile[];
}
export interface RenderResult {
+2
View File
@@ -3,6 +3,7 @@ import { installStreamAsyncIterator } from "../../lib/streamPolyfill";
import { initBrowserRendering } from "./browserInit";
import { runAssembleJob } from "./assembleJob";
import { renderInvoicePdf } from "./invoice";
import { registerJobFonts } from "./userFontJob";
import type { WorkerMessage, WorkerRequest } from "./jobs";
interface WorkerScope {
@@ -44,6 +45,7 @@ scope.onmessage = async (event) => {
const { job } = request;
try {
await started.catch(() => initBrowserRendering());
await registerJobFonts(job.prefs, job.userFonts);
const t0 = performance.now();
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
const ms = Math.round(performance.now() - t0);
+19
View File
@@ -0,0 +1,19 @@
import { registerUserFontSet } from "../fonts/register";
import { getUserFontFile, rememberUserFile, USER_FACES, type UserFontFile } from "../fonts/userFontStore";
import type { RenderPrefsV1 } from "../model/prefs";
/**
* Makes the user fonts a render's prefs name available to react-pdf and the measurer, before the render runs.
* `incoming` are the files the client sent with this job (only those this renderer has not been sent yet); the
* others are already held from earlier jobs. Registering a set is idempotent.
*/
export async function registerJobFonts(prefs: RenderPrefsV1, incoming: readonly UserFontFile[] = []): Promise<void> {
for (const file of incoming) rememberUserFile(file);
for (const face of USER_FACES) {
const ids = prefs.fonts?.[face];
if (!ids || ids.length === 0) continue;
const files = ids.map((id) => getUserFontFile(id));
if (files.some((f) => !f)) throw new Error(`The font imported for ${face} was not delivered to the renderer`);
await registerUserFontSet(face, files as UserFontFile[]);
}
}
+8 -3
View File
@@ -11,6 +11,11 @@ export interface TemplateCatalogEntry {
badge?: "Matches your original format";
/** No reference placeholder: the logo shows only when the user turns it on. */
logoOptIn?: boolean;
/**
* The licensed faces this template draws (a user-imported font may replace them). Mirrors the roles of the
* template; a test keeps the two in step.
*/
faces?: readonly ("Now" | "Gotham" | "Open Sauce One")[];
}
export const FAMILY_LABELS: Record<TemplateCatalogEntry["family"], string> = {
@@ -32,11 +37,11 @@ export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [
{ id: "tangerine-ledger", name: "Tangerine Ledger", family: "ledger", thumbnail: "/templates/tangerine-ledger.png" },
{ id: "slate-band", name: "Slate Band", family: "band", thumbnail: "/templates/slate-band.png" },
{ id: "teal-swoosh", name: "Teal Swoosh", family: "band", thumbnail: "/templates/teal-swoosh.png" },
{ id: "purple-pop", name: "Purple Pop", family: "northline", thumbnail: "/templates/purple-pop.png", logoOptIn: true },
{ id: "citrus-split", name: "Citrus Split", family: "northline", thumbnail: "/templates/citrus-split.png" },
{ id: "purple-pop", name: "Purple Pop", family: "northline", thumbnail: "/templates/purple-pop.png", logoOptIn: true, faces: ["Gotham", "Open Sauce One"] },
{ id: "citrus-split", name: "Citrus Split", family: "northline", thumbnail: "/templates/citrus-split.png", faces: ["Now", "Gotham"] },
{ id: "monolith", name: "Monolith", family: "standalone", thumbnail: "/templates/monolith.png" },
{ id: "cobalt-stripe", name: "Cobalt Stripe", family: "standalone", thumbnail: "/templates/cobalt-stripe.png" },
{ id: "marble", name: "Marble", family: "standalone", thumbnail: "/templates/marble.png", logoOptIn: true },
{ id: "marble", name: "Marble", family: "standalone", thumbnail: "/templates/marble.png", logoOptIn: true, faces: ["Now"] },
{ id: "highlighter", name: "Highlighter", family: "standalone", thumbnail: "/templates/highlighter.png", logoOptIn: true },
];
+19
View File
@@ -339,6 +339,25 @@ describe("closing and footer", () => {
expect(r.pdf.pages.map((p) => p.items.map((i) => i.str).join(" ")).join(" ")).toContain("Note 18");
});
it("when only the closing block moves to page 2 the page-1 gaps are squeezed enough to keep the items and totals on page 1", async () => {
// Row counts where the overflow before the closing block is within the gaps that help page 1 (A4 / Letter).
const cases: Array<["A4" | "LETTER", number]> = [["A4", 7]];
for (const [size, n] of cases) {
const r = await renderFixture({ name: "s", items: n, logo: "none" }, prefsFor(size, { templateId: "cobalt-stripe" }));
const where = (id: string) => {
const pages: number[] = [];
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id === id && pages.push(i)));
return pages;
};
const rowPages = new Set<number>();
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id?.startsWith(TABLE_ROW_PREFIX) && rowPages.add(i)));
expect([...rowPages], `${size} n=${n} rows`).toEqual([0]);
expect(where(TOTALS_ID), `${size} n=${n} totals`).toEqual([0]);
expect(where(CLOSING_ID), `${size} n=${n} closing`).toEqual([1]);
expect(r.result.pages).toBe(2);
}
});
it("an address, e-mail and phone that are too long for one row shrink and wrap inside the footer", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
+9 -5
View File
@@ -325,13 +325,17 @@ export function planCobalt(model: RenderModel, frame: PageFrame, v: CobaltVarian
const nominalTotal = nominalFor(tailBody);
const overflow = nominalTotal - frame.contentHeight;
// Everything on one page when the gaps can give back the overflow; failing that, keep the items and totals together on
// page 1 and let only the closing block move to the next page.
// page 1 and let only the closing block move to the next page (its own gaps stay nominal: squeezing them would take
// a share of the deficit away from the gaps that do help page 1).
const overflowBeforeClosing = nominalTotal - nominalClosing.h - frame.contentHeight;
const { terms: termsRange, footer: footerRange, ...beforeRanges } = ranges;
const slackBefore = Object.values(beforeRanges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
// The estimates are a hair low or high, so a page the gaps fill exactly could still spill by a fraction: keep a little air.
const air = (over: number) => Math.min(slack, over + FIT_AIR);
const fits = overflow > 0 && overflow <= slack ? air(overflow) : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? air(overflowBeforeClosing) : 0;
const deficit = Math.max(frame.heightDeficit, fits);
const g = elastic(ranges, deficit);
const air = (over: number, room: number) => Math.min(room, over + FIT_AIR);
let g: Record<"client" | "table" | "tail" | "terms" | "footer", number>;
if (overflow > 0 && overflow <= slack) g = elastic(ranges, Math.max(frame.heightDeficit, air(overflow, slack)));
else if (overflowBeforeClosing > 0 && overflowBeforeClosing <= slackBefore) g = { ...elastic(beforeRanges, Math.max(frame.heightDeficit, air(overflowBeforeClosing, slackBefore))), terms: termsRange.nominal, footer: footerRange.nominal };
else g = elastic(ranges, frame.heightDeficit);
const closing = closingFor(g);
const tailH = g.tail + tailBody;
const table = planTable(tailH);
+12 -11
View File
@@ -18,31 +18,32 @@ export const BAND_TO = "#ECEAEE";
* Marble's type (docs/templates/01-marble.md): Lustria in tracked caps for the title, headings, table heads and the
* amount due; "Now" (drawn in Jost, the bundled look-alike) for everything else. Jost sets about 12% narrower than Now,
* so every Now role carries the tracking that restores the reference widths (0.10 to 0.13 em on top of the design's
* 0.04 em). Lustria ascent 0.955, descent 0.327; Jost 1.07 / 0.375.
* 0.04 em). A real Now imported by the user replaces Jost, so those roles take the design's 0.04 em back through
* ownFont. Lustria ascent 0.955, descent 0.327; Jost 1.07 / 0.375.
*/
export const MARBLE_ROLES = defineRoles({
/** "INVOICE" / "TAX INVOICE": 25.18 pt, tracked 0.192 em, shrunk to the room the stack has. */
title: { face: L, weight: 400, size: 25.18, leading: 25.2, tracking: 0.192, caps: true, minSize: 13 },
/** The vendor's name at the header's left (the design leaves it empty). */
brand: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true, minSize: 9 },
meta: { face: N, weight: 400, size: 11.55, leading: 17.5, tracking: 0.105, minSize: 8 },
meta: { face: N, weight: 400, size: 11.55, leading: 17.5, tracking: 0.105, ownFont: { tracking: 0.04 }, minSize: 8 },
/** BILL TO:, PAYABLE TO:, SUPPLIER:. */
heading: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true },
body: { face: N, weight: 400, size: 11.55, leading: 18.2, tracking: 0.105, minSize: 8 },
body: { face: N, weight: 400, size: 11.55, leading: 18.2, tracking: 0.105, ownFont: { tracking: 0.04 }, minSize: 8 },
tableHead: { face: L, weight: 400, size: 11.68, leading: 16, tracking: 0.192, caps: true },
tableBody: { face: N, weight: 400, size: 10.57, leading: 17.4, tracking: 0.102 },
tableSub: { face: N, weight: 400, size: 8.5, leading: 11.5, tracking: 0.1 },
tableBody: { face: N, weight: 400, size: 10.57, leading: 17.4, tracking: 0.102, ownFont: { tracking: 0.04 } },
tableSub: { face: N, weight: 400, size: 8.5, leading: 11.5, tracking: 0.1, ownFont: { tracking: 0.04 } },
totalLabel: { face: L, weight: 400, size: 9, leading: 16, tracking: 0.192, caps: true },
totalValue: { face: N, weight: 400, size: 10.57, leading: 16, tracking: 0.102 },
totalValue: { face: N, weight: 400, size: 10.57, leading: 16, tracking: 0.102, ownFont: { tracking: 0.04 } },
/** AMOUNT DUE: 14.6 pt, tracked 0.192 em. */
due: { face: L, weight: 400, size: 14.6, leading: 20, tracking: 0.192, caps: true, minSize: 10 },
small: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13 },
small: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, ownFont: { tracking: 0.04 } },
smallLabel: { face: L, weight: 400, size: 9, leading: 15.3, tracking: 0.192, caps: true },
signName: { face: N, weight: 400, size: 10.57, leading: 15, tracking: 0.102 },
signDesignation: { face: N, weight: 400, size: 9.6, leading: 14, tracking: 0.13 },
fine: { face: N, weight: 400, size: 8, leading: 11, tracking: 0.1 },
signName: { face: N, weight: 400, size: 10.57, leading: 15, tracking: 0.102, ownFont: { tracking: 0.04 } },
signDesignation: { face: N, weight: 400, size: 9.6, leading: 14, tracking: 0.13, ownFont: { tracking: 0.04 } },
fine: { face: N, weight: 400, size: 8, leading: 11, tracking: 0.1, ownFont: { tracking: 0.04 } },
/** The footer band's contact lines. */
contact: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, minSize: 7 },
contact: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, ownFont: { tracking: 0.04 }, minSize: 7 },
footer: { face: N, weight: 400, size: 7 },
});
+9 -1
View File
@@ -21,6 +21,9 @@ import { CANCELLED_INK, CANCELLED_SENTENCE } from "../neutral/plan";
import { atRate, LABEL_GAP, NOTES_FLOW_GAP, paymentRows, planMonolith, REVERSE_GAP, roleOf, signatureOptions, type MonolithPlan } from "./plan";
import type { MonolithVariant } from "./tokens";
/** Room that must remain under the notes' label and first line, so the next lines are not squeezed into the page foot. */
const NOTES_HOLD = 70;
/** Layout-tree ids of the blocks that are Monolith's own (the harness ids are in classic/ids.ts). */
export const BRAND_ID = "monolith-brand";
export const IDS_ID = "monolith-ids";
@@ -348,10 +351,15 @@ export function MonolithBody({ model, frame, logo, prefs, variant: v }: Template
const plan = planMonolith(model, frame, v, logo, includesName({ model, prefs }));
const ctx: Ctx = { model, plan, v, logo };
const r = v.roles;
const notesFlow: ReactNode = plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : (
const notesFlow: ReactNode =
plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : (
<>
{/* A block that starts with less than a line of room left is cut into zero-height lines: it starts on the next page instead. */}
<TableGuard {...{ minPresenceAhead: NOTES_FLOW_GAP + NOTES_HOLD }} />
<View style={{ marginTop: NOTES_FLOW_GAP }}>
<Notes ctx={ctx} width={plan.W} />
</View>
</>
);
return (
<>
@@ -286,6 +286,41 @@ describe("long text", () => {
expect(r.pdf.pages.map((p, i) => textsOf(r, i)).join(" ")).toContain("Note 18");
});
it("when only the closing block moves to page 2 the page-1 gaps are squeezed enough to keep the items and totals on page 1", async () => {
// Row counts where the overflow before the closing block is within the gaps that help page 1 (A4 / Letter).
const cases: Array<["A4" | "LETTER", number]> = [["A4", 4], ["A4", 5], ["LETTER", 4]];
for (const [size, n] of cases) {
const r = await renderFixture({ name: "s", items: n, logo: "none" }, prefsFor(size, { templateId: "monolith" }));
const where = (id: string) => {
const pages: number[] = [];
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id === id && pages.push(i)));
return pages;
};
const rowPages = new Set<number>();
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id?.startsWith(TABLE_ROW_PREFIX) && rowPages.add(i)));
expect([...rowPages], `${size} n=${n} rows`).toEqual([0]);
expect(where(TOTALS_ID), `${size} n=${n} totals`).toEqual([0]);
expect(where(CLOSING_ID), `${size} n=${n} closing`).toEqual([1]);
expect(r.result.pages).toBe(2);
}
});
it("flow notes never start with less than a line of room: the block starts on the next page instead of collapsing into zero-height lines", async () => {
const base = FIXTURES.find((f) => f.name === "long-notes")!;
// Row counts where the notes' label used to land in the last few points of a page (A4 / Letter).
const cases: Array<["A4" | "LETTER", number]> = [["A4", 3], ["A4", 14], ["LETTER", 13]];
for (const [size, n] of cases) {
const r = await renderFixture({ ...base, items: n, logo: "none" }, prefsFor(size, { templateId: "monolith" }));
expect(r.result.issues.filter((i) => i.severity === "error"), `${size} n=${n}`).toEqual([]);
const frame = frameForPrefs(r.prefs);
const label = nodes(r.result.core.layout).find((x) => x.type === "text" && x.lines?.some((l) => /^notes/i.test(l.text)))!;
let page = -1;
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x === label && (page = i)));
expect(label.box.y + 70, `${size} n=${n} label room`).toBeLessThanOrEqual(frame.page.height - frame.pad.bottom);
expect(page).toBeGreaterThan(0);
}
});
it("an over-long vendor name wraps right-aligned in its column and never reaches the title", async () => {
const r = await renderFixture({ ...FIXTURES.find((f) => f.name === "id-tokens")!, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
const brand = textLines(byId(r, BRAND_ID));
+10 -5
View File
@@ -235,15 +235,20 @@ export function planMonolith(model: RenderModel, frame: PageFrame, v: MonolithVa
const notesBlock = notesBeside ? 0 : NOTES_FLOW_GAP + flowH;
const nominalTotal = headH + v.headRuleGap + v.rule + ranges.infoTop.nominal + infoH + ranges.infoBottom.nominal + v.rule + ranges.band.nominal + tableTotal + tailH + notesBlock + closingNominal;
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
// The closing block's own gaps cannot help page 1 once it has moved to page 2.
const { closeBand: closeBandRange, closeBottom: closeBottomRange, ...beforeRanges } = ranges;
const slackBefore = Object.values(beforeRanges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
const overflow = nominalTotal - frame.contentHeight;
// Everything on one page when the gaps can give back the overflow; failing that, keep the items and totals together on
// page 1 and let only the closing block move to the next page.
// page 1 and let only the closing block move to the next page (its own gaps stay nominal: squeezing them would take
// a share of the deficit away from the gaps that do help page 1).
const overflowBeforeClosing = nominalTotal - closingNominal - frame.contentHeight;
// The estimates are a hair low or high, so a page the gaps fill exactly could still spill by a fraction: keep a little air.
const air = (over: number) => Math.min(slack, over + FIT_AIR);
const fits = overflow > 0 && overflow <= slack ? air(overflow) : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? air(overflowBeforeClosing) : 0;
const deficit = Math.max(frame.heightDeficit, fits);
const g = elastic(ranges, deficit);
const air = (over: number, room: number) => Math.min(room, over + FIT_AIR);
let g: Record<"band" | "infoTop" | "infoBottom" | "closeBand" | "closeBottom" | "rowPad", number>;
if (overflow > 0 && overflow <= slack) g = elastic(ranges, Math.max(frame.heightDeficit, air(overflow, slack)));
else if (overflowBeforeClosing > 0 && overflowBeforeClosing <= slackBefore) g = { ...elastic(beforeRanges, Math.max(frame.heightDeficit, air(overflowBeforeClosing, slackBefore))), closeBand: closeBandRange.nominal, closeBottom: closeBottomRange.nominal };
else g = elastic(ranges, frame.heightDeficit);
const closingH = closingFor(g);
const padY = n ? g.rowPad / (2 * n) : padYn;
const table = planTable(padY, tailH);
+11 -11
View File
@@ -28,21 +28,21 @@ export const CITRUS_ROLES = defineRoles({
metaStrong: { face: DM, weight: 700, size: 12.36, leading: 16, minSize: 8 },
billedLabel: { face: DM, weight: 700, size: 12.4, leading: 17, caps: true },
clientName: { face: DM, weight: 700, size: 14.87, leading: 19, caps: true },
clientLine: { face: G, weight: 400, size: gotham(10.5), leading: 15.5 },
headService: { face: G, weight: 700, size: gotham(15.17), leading: 18, tracking: 0.15, caps: true },
headNum: { face: G, weight: 700, size: gotham(14.17), leading: 18, caps: true },
tableBody: { face: G, weight: 400, size: gotham(12), leading: 15 },
clientLine: { face: G, weight: 400, size: gotham(10.5), ownFont: { size: 10.5 }, leading: 15.5 },
headService: { face: G, weight: 700, size: gotham(15.17), ownFont: { size: 15.17 }, leading: 18, tracking: 0.15, caps: true },
headNum: { face: G, weight: 700, size: gotham(14.17), ownFont: { size: 14.17 }, leading: 18, caps: true },
tableBody: { face: G, weight: 400, size: gotham(12), ownFont: { size: 12 }, leading: 15 },
tableSub: { face: G, weight: 400, size: 9, leading: 11.5 },
totalLabel: { face: G, weight: 700, size: gotham(12.5), leading: 17, caps: true },
totalValue: { face: G, weight: 400, size: gotham(12.5), leading: 17 },
grand: { face: G, weight: 700, size: gotham(14.7), leading: 18, caps: true },
grandValue: { face: G, weight: 700, size: gotham(14.7), leading: 18 },
totalLabel: { face: G, weight: 700, size: gotham(12.5), ownFont: { size: 12.5 }, leading: 17, caps: true },
totalValue: { face: G, weight: 400, size: gotham(12.5), ownFont: { size: 12.5 }, leading: 17 },
grand: { face: G, weight: 700, size: gotham(14.7), ownFont: { size: 14.7 }, leading: 18, caps: true },
grandValue: { face: G, weight: 700, size: gotham(14.7), ownFont: { size: 14.7 }, leading: 18 },
words: { face: DM, weight: 700, size: 11, leading: 14 },
wordsLabel: { face: DM, weight: 700, size: 11, leading: 14 },
sectionLabel: { face: DM, weight: 700, size: 12, leading: 16, caps: true },
bank: { face: G, weight: 400, size: gotham(11), leading: 15 },
bullet: { face: G, weight: 400, size: gotham(12), leading: 16.5 },
contact: { face: G, weight: 400, size: gotham(12.77), leading: 18, minSize: 7 },
bank: { face: G, weight: 400, size: gotham(11), ownFont: { size: 11 }, leading: 15 },
bullet: { face: G, weight: 400, size: gotham(12), ownFont: { size: 12 }, leading: 16.5 },
contact: { face: G, weight: 400, size: gotham(12.77), ownFont: { size: 12.77 }, leading: 18, minSize: 7 },
small: { face: DM, weight: 400, size: 9, leading: 12 },
fine: { face: DM, weight: 400, size: 7, leading: 10 },
signName: { face: DM, weight: 700, size: 9.5, leading: 13 },
+10 -10
View File
@@ -22,25 +22,25 @@ export const PURPLE_ROLES = defineRoles({
wordmark: { face: "Open Sauce One", weight: 800, size: 94.07, leading: 84, tracking: -0.08, minSize: 48 },
taxCaption: { face: DM, weight: 700, size: 14, leading: 18, tracking: 0.15, caps: true },
brand: { face: DM, weight: 700, size: 17.77, leading: 21, minSize: 12 },
pill: { face: G, weight: 700, size: gotham(13.39), leading: 17, tracking: 0.13, caps: true, minSize: 8.5 },
label: { face: G, weight: 700, size: gotham(12), leading: 16, caps: true },
clientName: { face: G, weight: 700, size: gotham(14.39), leading: 18, caps: true },
clientLine: { face: G, weight: 400, size: gotham(10.16), leading: 15.5 },
pill: { face: G, weight: 700, size: gotham(13.39), ownFont: { size: 13.39 }, leading: 17, tracking: 0.13, caps: true, minSize: 8.5 },
label: { face: G, weight: 700, size: gotham(12), ownFont: { size: 12 }, leading: 16, caps: true },
clientName: { face: G, weight: 700, size: gotham(14.39), ownFont: { size: 14.39 }, leading: 18, caps: true },
clientLine: { face: G, weight: 400, size: gotham(10.16), ownFont: { size: 10.16 }, leading: 15.5 },
amount: { face: DM, weight: 700, size: 32.74, leading: 36, minSize: 20, tnum: true },
date: { face: DM, weight: 700, size: 14.05, leading: 19.5, caps: true, minSize: 10 },
tableHead: { face: DM, weight: 700, size: 14, leading: 18, caps: true },
tableBody: { face: DM, weight: 400, size: 11.83, leading: 15 },
tableSub: { face: DM, weight: 400, size: 9, leading: 11.5 },
totalLabel: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true },
totalValue: { face: G, weight: 400, size: gotham(13), leading: 17 },
grand: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true },
grandValue: { face: G, weight: 700, size: gotham(13), leading: 17 },
totalLabel: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17, caps: true },
totalValue: { face: G, weight: 400, size: gotham(13), ownFont: { size: 13 }, leading: 17 },
grand: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17, caps: true },
grandValue: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17 },
words: { face: DM, weight: 400, size: 10, leading: 13 },
wordsLabel: { face: DM, weight: 700, size: 10, leading: 13 },
termsLabel: { face: DM, weight: 700, size: 13, leading: 17, caps: true },
bullet: { face: DM, weight: 400, size: 11, leading: 14.2 },
bank: { face: G, weight: 400, size: gotham(11), leading: 12.5 },
contact: { face: G, weight: 400, size: gotham(10.14), leading: 15, minSize: 7 },
bank: { face: G, weight: 400, size: gotham(11), ownFont: { size: 11 }, leading: 12.5 },
contact: { face: G, weight: 400, size: gotham(10.14), ownFont: { size: 10.14 }, leading: 15, minSize: 7 },
small: { face: DM, weight: 400, size: 9, leading: 12 },
fine: { face: DM, weight: 400, size: 7, leading: 10 },
signName: { face: DM, weight: 700, size: 9.5, leading: 13 },
+1 -1
View File
@@ -5,7 +5,7 @@ import type { GapRange } from "../neutral/variant";
/**
* Gotham (commercial) is drawn with Montserrat, which runs about 4% wider at the same size. Every Gotham-derived
* size is multiplied by this when a token is defined, so the substitute takes the width the design has. A real
* Gotham imported over the licensed name would need these tokens at factor 1.
* Gotham imported over the licensed name takes the design size back: each such token carries it as `ownFont.size`.
*/
export const GOTHAM_SIZE = 0.96;
const round2 = (n: number) => Math.round(n * 100) / 100;
+175
View File
@@ -148,6 +148,23 @@ body {
display: none !important;
}
.voiced-toasts {
position: fixed;
top: 3.5rem; /* below the 3rem Carbon header */
right: 1rem;
z-index: 9100;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: calc(100vw - 2rem);
pointer-events: none;
}
.voiced-toasts .cds--toast-notification {
pointer-events: auto;
margin: 0;
}
.voiced-page {
padding: 2rem;
max-width: 1600px;
@@ -523,3 +540,161 @@ body {
margin-top: 0.5rem;
font-size: 0.75rem;
}
/* ---------- Settings: Data tab ---------- */
.voiced-section-title {
font-size: 1.25rem;
font-weight: 400;
margin-bottom: 0.5rem;
}
.voiced-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
}
.voiced-list {
margin-left: 1.25rem;
list-style: disc;
}
/* ---------- Invoice history and detail ---------- */
.voiced-num {
text-align: right;
}
.voiced-history__fy {
font-size: 1.25rem;
font-weight: 400;
margin-bottom: 0.5rem;
}
.voiced-history__tiles,
.voiced-detail__tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.voiced-history__amount,
.voiced-detail__amount {
font-size: 1.75rem;
font-weight: 400;
}
.voiced-history__tile--overdue {
border-left: 4px solid var(--cds-support-error);
}
.voiced-history__filters {
display: grid;
grid-template-columns: minmax(14rem, 2fr) repeat(4, minmax(9rem, 1fr)) auto;
gap: 1rem;
align-items: end;
margin-bottom: 1rem;
}
.voiced-history__export {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.voiced-history__row {
cursor: pointer;
}
.voiced-history__totals {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 2rem;
padding: 0.75rem 1rem;
background: var(--cds-layer-accent);
border-top: 1px solid var(--cds-border-subtle);
}
.voiced-detail__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
}
.voiced-detail__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin: 1rem 0;
}
.voiced-detail__grid {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: 2rem;
align-items: start;
}
@media (max-width: 1100px) {
.voiced-detail__grid,
.voiced-history__filters {
grid-template-columns: 1fr;
}
}
.voiced-detail__h2 {
font-size: 1.25rem;
font-weight: 400;
margin-bottom: 0.75rem;
}
.voiced-detail__parties {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.voiced-detail__field {
margin-bottom: 0.75rem;
}
.voiced-detail__pre {
white-space: pre-line;
}
.voiced-detail__totals {
margin: 1rem 0 0.5rem auto;
max-width: 22rem;
}
.voiced-detail__totals > div {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
}
.voiced-detail__grand {
border-top: 1px solid var(--cds-border-strong);
font-weight: 600;
}
.voiced-detail__preview {
position: sticky;
top: 4rem;
}
.voiced-detail__unarchived {
padding: 1.5rem;
background: var(--cds-layer-accent);
}
.voiced-detail__unarchived p {
margin-bottom: 1rem;
}
+133 -94
View File
@@ -3,7 +3,6 @@ import {
Button,
Column,
Grid,
InlineNotification,
Link,
NumberInput,
RadioButton,
@@ -19,6 +18,11 @@ import {
TableRow,
TextArea,
TextInput,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
Tile,
Toggle,
} from "@carbon/react";
@@ -35,7 +39,12 @@ import {
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
import { ImagePicker } from "../components/ImagePicker";
import { LogoBranding } from "../components/LogoBranding";
import DataBackupPanel from "../components/DataBackupPanel";
import DiagnosticsPanel from "../components/DiagnosticsPanel";
import { FontOverrideTable } from "../components/FontOverrideTable";
import { useToast } from "../components/ToastProvider";
import { SETTINGS_TABS, loadSettingsTab, saveSettingsTab } from "../lib/settingsTab";
import SeriesSettings from "./SeriesSettings";
const blankBank = (): BankAccount => ({
id: null,
@@ -60,14 +69,20 @@ export default function AppSettings({
const [banks, setBanks] = useState<BankAccount[]>([]);
const [editing, setEditing] = useState<BankAccount | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const toast = useToast();
// One form state for every tab and one Save button, so edits survive switching tabs.
const [tab, setTab] = useState(loadSettingsTab);
const selectTab = (index: number) => {
setTab(index);
saveSettingsTab(index);
};
const loadBanks = async () => {
try {
setBanks(await api.listBanks());
} catch (e) {
setError(String(e));
toast.error("Could not load bank accounts", String(e));
}
};
@@ -91,18 +106,17 @@ export default function AppSettings({
const saveAll = async () => {
if (gstinProblem) {
setError(gstinProblem);
selectTab(0);
toast.error("Settings not saved", gstinProblem);
return;
}
setBusy(true);
setError(null);
setNotice(null);
try {
await api.saveSettings(form);
setNotice("Settings saved.");
toast.success("Settings saved");
onSaved();
} catch (e) {
setError(String(e));
toast.error("Could not save settings", String(e));
} finally {
setBusy(false);
}
@@ -111,7 +125,6 @@ export default function AppSettings({
const saveBank = async () => {
if (!editing) return;
setBusy(true);
setError(null);
try {
const saved = await api.saveBank(editing);
if (saved.isDefault || form.defaultBankId === null) {
@@ -119,8 +132,9 @@ export default function AppSettings({
}
setEditing(null);
await loadBanks();
toast.success("Bank account saved", "Click Save settings to keep a changed default account.");
} catch (e) {
setError(String(e));
toast.error("Could not save bank account", String(e));
} finally {
setBusy(false);
}
@@ -138,30 +152,29 @@ export default function AppSettings({
if (form.defaultBankId === bank.id) set("defaultBankId", null);
await loadBanks();
} catch (e) {
setError(String(e));
toast.error("Could not delete bank account", String(e));
}
};
return (
<div className="voiced-page">
<h1 className="voiced-page__title">Settings</h1>
<p className="voiced-page__subtitle">Vendor details, defaults and bank accounts.</p>
{error ? (
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
) : null}
{notice ? (
<InlineNotification
kind="success"
lowContrast
title="Saved"
subtitle={notice}
onCloseButtonClick={() => setNotice(null)}
/>
) : null}
<p className="voiced-page__subtitle">Business details, branding, defaults and bank accounts.</p>
<Tabs selectedIndex={tab} onChange={({ selectedIndex }) => selectTab(selectedIndex)}>
<TabList aria-label="Settings sections" contained scrollIntoView>
<Tab>Business</Tab>
<Tab>Branding</Tab>
<Tab>Bank &amp; payments</Tab>
<Tab>Invoice defaults</Tab>
<Tab>Numbering</Tab>
<Tab>Templates &amp; export</Tab>
<Tab>Fonts</Tab>
<Tab>Data</Tab>
</TabList>
<TabPanels>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Vendor details</h3>
<Grid narrow>
<Column sm={4} md={8} lg={12}>
<RadioButtonGroup
@@ -276,9 +289,9 @@ export default function AppSettings({
</Column>
</Grid>
</section>
</TabPanel>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Branding</h3>
<Tile>
<LogoBranding
idPrefix="s"
@@ -288,71 +301,8 @@ export default function AppSettings({
/>
</Tile>
</section>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Defaults</h3>
<Grid narrow>
{registered ? (
<>
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-taxRate"
label="Default tax rate (%)"
min={0}
max={100}
step={0.5}
value={form.defaultTaxRate}
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput
id="s-hsn"
labelText="Default HSN/SAC"
value={form.defaultHsnSac}
onChange={(e) => set("defaultHsnSac", e.target.value)}
/>
</Column>
</>
) : null}
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-terms"
label="Payment terms (days)"
min={0}
max={365}
value={form.paymentTermsDays}
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={8} lg={6}>
<Select
id="s-theme"
labelText="Theme"
value={form.theme}
onChange={(e) => set("theme", e.target.value)}
>
<SelectItem value="g10" text="Carbon light (g10)" />
<SelectItem value="g100" text="Carbon dark (g100)" />
</Select>
</Column>
{registered ? (
<Column sm={4} md={8} lg={16}>
<RadioButtonGroup
legendText="Default tax treatment"
name="s-taxType"
valueSelected={form.defaultTaxType}
onChange={(value) => set("defaultTaxType", value as TaxType)}
>
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
</RadioButtonGroup>
</Column>
) : null}
</Grid>
</section>
</TabPanel>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h3>Bank accounts</h3>
@@ -479,6 +429,95 @@ export default function AppSettings({
</div>
) : null}
</section>
</TabPanel>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<Grid narrow>
{registered ? (
<>
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-taxRate"
label="Default tax rate (%)"
min={0}
max={100}
step={0.5}
value={form.defaultTaxRate}
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput
id="s-hsn"
labelText="Default HSN/SAC"
value={form.defaultHsnSac}
onChange={(e) => set("defaultHsnSac", e.target.value)}
/>
</Column>
</>
) : null}
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-terms"
label="Payment terms (days)"
min={0}
max={365}
value={form.paymentTermsDays}
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={8} lg={6}>
<Select
id="s-theme"
labelText="Theme"
value={form.theme}
onChange={(e) => set("theme", e.target.value)}
>
<SelectItem value="g10" text="Carbon light (g10)" />
<SelectItem value="g100" text="Carbon dark (g100)" />
</Select>
</Column>
{registered ? (
<Column sm={4} md={8} lg={16}>
<RadioButtonGroup
legendText="Default tax treatment"
name="s-taxType"
valueSelected={form.defaultTaxType}
onChange={(value) => set("defaultTaxType", value as TaxType)}
>
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
</RadioButtonGroup>
</Column>
) : null}
</Grid>
</section>
</TabPanel>
<TabPanel>
<SeriesSettings embedded active={tab === SETTINGS_TABS.findIndex((t) => t.id === "numbering")} />
</TabPanel>
<TabPanel>
<section className="voiced-stack">
<p>
The template, page size and margins are chosen next to the preview on the New invoice screen. The
default page setup is saved from there.
</p>
<p className="voiced-muted">Export mode (searchable or flattened) is chosen when you issue an invoice.</p>
</section>
</TabPanel>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<Tile>
<FontOverrideTable />
</Tile>
</section>
</TabPanel>
<TabPanel>
<DataBackupPanel />
</TabPanel>
</TabPanels>
</Tabs>
<Button renderIcon={Save} onClick={saveAll} disabled={busy}>
{busy ? "Saving…" : "Save settings"}
+246
View File
@@ -0,0 +1,246 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
Button,
DataTable,
InlineLoading,
Modal,
Tab,
TabList,
TabPanel,
TabPanels,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
TableToolbar,
TableToolbarContent,
TableToolbarSearch,
Tabs,
Tag,
} from "@carbon/react";
import { Add, Edit, TrashCan } from "@carbon/icons-react";
import { api } from "../lib/api";
import { GST_CATEGORY_LABELS, INDIAN_STATES, type Client } from "../lib/types";
import ClientModal from "../components/ClientModal";
import ItemPresetsPanel from "../components/ItemPresetsPanel";
import { useToast } from "../components/ToastProvider";
const HEADERS = [
{ key: "name", header: "Name" },
{ key: "gstin", header: "GSTIN" },
{ key: "category", header: "GST category" },
{ key: "place", header: "City / State" },
{ key: "invoices", header: "Invoices" },
{ key: "actions", header: "" },
];
const stateName = (code: string) => INDIAN_STATES.find((s) => s.code === code)?.name ?? "";
export default function Clients({ active }: { active: boolean }) {
const toast = useToast();
const [clients, setClients] = useState<Client[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<Client | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [deleting, setDeleting] = useState<Client | null>(null);
const [busy, setBusy] = useState(false);
const launcher = useRef<HTMLElement | null>(null);
const load = useCallback(async () => {
try {
setClients(await api.listClients());
} catch (e) {
toast.error("Could not load clients", String(e));
} finally {
setLoading(false);
}
}, [toast]);
// The view stays mounted, so refetch whenever it is shown again.
useEffect(() => {
if (active) void load();
}, [active, load]);
const openEditor = (client: Client | null, trigger: HTMLElement) => {
launcher.current = trigger;
setEditing(client);
setModalOpen(true);
};
const confirmDelete = async () => {
if (!deleting || deleting.id === null) return;
setBusy(true);
try {
await api.deleteClient(deleting.id);
toast.success("Client deleted", deleting.name);
setDeleting(null);
await load();
} catch (e) {
toast.error("Could not delete client", String(e));
} finally {
setBusy(false);
}
};
const byId = new Map(clients.map((c) => [String(c.id), c]));
const rows = clients.map((c) => ({
id: String(c.id),
name: c.name,
gstin: c.gstin || "—",
category: GST_CATEGORY_LABELS[c.gstCategory] ?? c.gstCategory,
place: [c.city, stateName(c.stateCode)].filter(Boolean).join(", ") || "—",
invoices: c.invoiceCount ?? 0,
actions: "",
}));
const clientsTable = loading ? (
<InlineLoading description="Loading clients" />
) : (
<DataTable rows={rows} headers={HEADERS} isSortable>
{({ rows: tableRows, headers, getHeaderProps, getRowProps, getTableProps, onInputChange }) => (
<TableContainer>
<TableToolbar>
<TableToolbarContent>
<TableToolbarSearch
persistent
placeholder="Search clients"
labelText="Search clients"
onChange={(e) => onInputChange(e as React.ChangeEvent<HTMLInputElement>)}
/>
<Button
renderIcon={Add}
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(null, e.currentTarget)}
>
Add client
</Button>
</TableToolbarContent>
</TableToolbar>
<Table {...getTableProps()} aria-label="Clients">
<TableHead>
<TableRow>
{headers.map((header) => {
const { key, ...headerProps } = getHeaderProps({ header });
return header.key === "actions" ? (
<TableHeader key={key} aria-label="Actions" />
) : (
<TableHeader key={key} {...headerProps}>
{header.header}
</TableHeader>
);
})}
</TableRow>
</TableHead>
<TableBody>
{tableRows.map((row) => {
const client = byId.get(row.id);
const { key, ...rowProps } = getRowProps({ row });
return (
<TableRow key={key} {...rowProps}>
{row.cells.map((cell) => {
if (cell.info.header === "actions") {
return (
<TableCell key={cell.id}>
{client ? (
<div className="voiced-header-action">
<Button
kind="ghost"
size="sm"
renderIcon={Edit}
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(client, e.currentTarget)}
>
Edit
</Button>
<Button
kind="ghost"
size="sm"
renderIcon={TrashCan}
onClick={(e: React.MouseEvent<HTMLElement>) => {
launcher.current = e.currentTarget;
setDeleting(client);
}}
>
Delete
</Button>
</div>
) : null}
</TableCell>
);
}
if (cell.info.header === "category" && client) {
return (
<TableCell key={cell.id}>
<Tag type={client.gstCategory === "unregistered" ? "gray" : "blue"} size="sm">
{cell.value}
</Tag>
</TableCell>
);
}
return <TableCell key={cell.id}>{cell.value}</TableCell>;
})}
</TableRow>
);
})}
</TableBody>
</Table>
{clients.length === 0 ? <div className="voiced-empty">No clients yet. Add one to reuse it on invoices.</div> : null}
</TableContainer>
)}
</DataTable>
);
const count = deleting?.invoiceCount ?? 0;
return (
<div className="voiced-page">
<h1 className="voiced-page__title">Clients</h1>
<p className="voiced-page__subtitle">Saved clients and item presets for faster invoicing.</p>
<Tabs>
<TabList aria-label="Clients and presets" contained>
<Tab>Clients</Tab>
<Tab>Item presets</Tab>
</TabList>
<TabPanels>
<TabPanel>{clientsTable}</TabPanel>
<TabPanel>
<ItemPresetsPanel active={active} />
</TabPanel>
</TabPanels>
</Tabs>
<ClientModal
open={modalOpen}
client={editing}
launcherRef={launcher}
onClose={() => setModalOpen(false)}
onSaved={() => {
setModalOpen(false);
void load();
}}
/>
<Modal
danger
open={deleting !== null}
modalHeading="Delete client"
primaryButtonText={busy ? "Deleting…" : "Delete"}
secondaryButtonText="Cancel"
primaryButtonDisabled={busy}
onRequestClose={() => setDeleting(null)}
onRequestSubmit={() => void confirmDelete()}
launcherButtonRef={launcher}
size="xs"
>
<p>
Delete “{deleting?.name}”?{" "}
{count > 0
? `${count} invoice${count === 1 ? "" : "s"} refer${count === 1 ? "s" : ""} to this client. They keep their own copy of the client's name, address and GSTIN, so they are not changed.`
: "No invoices refer to this client."}
</p>
</Modal>
</div>
);
}
+529
View File
@@ -0,0 +1,529 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
Button,
InlineLoading,
InlineNotification,
Modal,
OverflowMenu,
OverflowMenuItem,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
Tag,
TextInput,
Tile,
} from "@carbon/react";
import { ArrowLeft, TrashCan } from "@carbon/icons-react";
import { api, type ArchiveStatus } from "../lib/api";
import { formatAmount, formatDate } from "../lib/format";
import { formatPaise } from "../lib/money";
import { paymentModeLabel, statusBadge, type Payment } from "../lib/payments";
import { createExportDeps } from "../lib/exportDeps";
import { renderAndArchive, type ExportSource } from "../lib/exportFlow";
import { toDraftPayload } from "../lib/draft";
import { invoiceToFormState } from "../lib/duplicate";
import type { ExportMode } from "../lib/filename";
import { useInvoiceExport } from "../hooks/useInvoiceExport";
import { useReturnFocus } from "../hooks/useReturnFocus";
import ExportFeedback from "../components/ExportFeedback";
import PdfPreview from "../components/PdfPreview";
import RecordPaymentModal from "../components/RecordPaymentModal";
import { useToast } from "../components/ToastProvider";
import { INDIAN_STATES, type Invoice, type Settings } from "../lib/types";
interface Props {
invoiceId: number;
settings: Settings;
onBack: () => void;
/** A draft was created from this invoice; the shell opens it in the New invoice form. */
onDuplicated: (draftId: number) => void;
}
interface Frozen {
vendorName?: string;
vendorAddress?: string;
vendorGstin?: string;
vendorPan?: string;
vendorEmail?: string;
vendorPhone?: string;
}
function parseSnapshot(text: string): Frozen {
try {
return text ? (JSON.parse(text) as Frozen) : {};
} catch {
return {};
}
}
const stateName = (code: string) => {
const s = INDIAN_STATES.find((x) => x.code === code);
return s ? `${code} - ${s.name}` : code;
};
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="voiced-detail__field">
<dt className="voiced-muted">{label}</dt>
<dd>{children || "—"}</dd>
</div>
);
}
export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicated }: Props) {
const toast = useToast();
const [invoice, setInvoice] = useState<Invoice | null>(null);
const [payments, setPayments] = useState<Payment[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [archive, setArchive] = useState<ArchiveStatus | null>(null);
const [pdf, setPdf] = useState<Uint8Array | null>(null);
const [pdfError, setPdfError] = useState<string | null>(null);
const [archiving, setArchiving] = useState(false);
const [recordOpen, setRecordOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Payment | null>(null);
const [cancelOpen, setCancelOpen] = useState(false);
const [reason, setReason] = useState("");
const [duplicating, setDuplicating] = useState(false);
useReturnFocus(deleteTarget !== null);
useReturnFocus(cancelOpen);
// Guards against a slow load for a previous id landing after the id changed.
const generation = useRef(0);
const loadPdf = useCallback(async (id: number, gen: number) => {
setPdfError(null);
try {
const status = await api.archiveStatus(id);
if (gen !== generation.current) return;
setArchive(status);
if (status.archived) {
const bytes = await api.readArchive(id);
if (gen === generation.current) setPdf(bytes);
} else {
setPdf(null);
}
} catch (e) {
if (gen === generation.current) setPdfError(String(e));
}
}, []);
const loadData = useCallback(async (id: number, gen: number) => {
try {
const [inv, pays] = await Promise.all([api.getInvoice(id), api.listPayments(id)]);
if (gen !== generation.current) return;
setInvoice(inv);
setPayments(pays);
setLoadError(null);
} catch (e) {
if (gen === generation.current) setLoadError(String(e));
}
}, []);
useEffect(() => {
const gen = ++generation.current;
setInvoice(null);
setPdf(null);
setArchive(null);
void loadData(invoiceId, gen);
void loadPdf(invoiceId, gen);
}, [invoiceId, loadData, loadPdf]);
const refresh = () => loadData(invoiceId, generation.current);
const exp = useInvoiceExport({
settings,
bank: null,
// An export that had to archive the original first: show it in the preview.
onInvoiceUpdated: () => void loadPdf(invoiceId, generation.current),
});
const busy = exp.busy || archiving || duplicating;
const onExport = (mode: ExportMode, source: ExportSource) => {
if (!invoice) return;
void exp.start(invoice, mode, source).catch((e) => toast.error("Export failed", String(e)));
};
const onArchive = async () => {
if (!invoice) return;
setArchiving(true);
try {
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
if (outcome.archived) {
toast.success(`Invoice ${invoice.number} is archived`);
await Promise.all([loadPdf(invoiceId, generation.current), refresh()]);
} else {
toast.error("Could not archive the PDF", outcome.error ?? "The PDF could not be archived.");
}
} catch (e) {
toast.error("Could not archive the PDF", String(e));
} finally {
setArchiving(false);
}
};
const onDuplicate = async () => {
if (!invoice) return;
setDuplicating(true);
try {
const id = await api.saveDraft(null, toDraftPayload(invoiceToFormState(invoice, settings)));
toast.success("Draft created from this invoice", "Review it, then issue it as a new invoice.");
onDuplicated(id);
} catch (e) {
toast.error("Could not duplicate the invoice", String(e));
} finally {
setDuplicating(false);
}
};
const onCancelInvoice = async () => {
if (!invoice) return;
try {
await api.cancelInvoice(invoice.id, reason);
toast.success(`Invoice ${invoice.number} cancelled`);
setCancelOpen(false);
setReason("");
await refresh();
} catch (e) {
setCancelOpen(false);
toast.error("Could not cancel the invoice", String(e));
}
};
const onDeletePayment = async () => {
if (!deleteTarget) return;
const target = deleteTarget;
setDeleteTarget(null);
try {
await api.deletePayment(target.id);
toast.success("Payment deleted", `${formatPaise(target.amountPaise + target.tdsPaise)} on ${formatDate(target.paidOn)}`);
await refresh();
} catch (e) {
toast.error("Could not delete the payment", String(e));
}
};
if (loadError) {
return (
<div className="voiced-page">
<Button kind="ghost" size="sm" renderIcon={ArrowLeft} onClick={onBack}>
Back to invoices
</Button>
<InlineNotification kind="error" lowContrast hideCloseButton title="Could not load the invoice" subtitle={loadError} />
</div>
);
}
if (!invoice) {
return (
<div className="voiced-page">
<InlineLoading description="Loading invoice" />
</div>
);
}
const badge = statusBadge(invoice.status, invoice.paymentStatus);
const vendor = parseSnapshot(invoice.vendorSnapshot);
const total = invoice.totalPaise ?? Math.round(invoice.total * 100);
const balance = invoice.balancePaise ?? 0;
const canPay = invoice.status === "issued" && balance > 0;
const half = invoice.taxRate / 2;
return (
<div className="voiced-page voiced-detail">
<Button kind="ghost" size="sm" renderIcon={ArrowLeft} onClick={onBack}>
Back to invoices
</Button>
<div className="voiced-detail__head">
<h1 className="voiced-page__title">{invoice.number}</h1>
<Tag type={badge.type}>{badge.label}</Tag>
{invoice.status === "issued" && invoice.paymentStatus === "overdue" && (invoice.paidPaise ?? 0) + (invoice.tdsPaise ?? 0) > 0 ? (
<Tag type="blue" size="sm">
Partly paid
</Tag>
) : null}
{archive && !archive.archived ? (
<Tag type="warm-gray" size="sm">
Not archived
</Tag>
) : null}
</div>
<p className="voiced-page__subtitle">
{invoice.clientName || "No client name"} · issued {formatDate(invoice.invoiceDate)}
{invoice.dueDate ? ` · due ${formatDate(invoice.dueDate)}` : ""}
</p>
{invoice.status === "cancelled" ? (
<InlineNotification
kind="warning"
lowContrast
hideCloseButton
title="This invoice is cancelled"
subtitle={invoice.cancelReason ? `Reason: ${invoice.cancelReason}` : "Its number is not reused."}
/>
) : null}
<div className="voiced-detail__actions" role="toolbar" aria-label="Invoice actions">
{canPay ? (
<Button size="md" onClick={() => setRecordOpen(true)} disabled={busy}>
Record payment
</Button>
) : null}
<Button kind="tertiary" size="md" onClick={() => onExport("searchable", "original")} disabled={busy}>
Export PDF
</Button>
<Button kind="tertiary" size="md" onClick={() => onExport("flattened", "original")} disabled={busy}>
Export flattened
</Button>
<Button kind="tertiary" size="md" onClick={() => void onDuplicate()} disabled={busy}>
{duplicating ? "Duplicating…" : "Duplicate"}
</Button>
<OverflowMenu aria-label="More actions" iconDescription="More actions" flipped size="md" disabled={busy}>
<OverflowMenuItem itemText="Re-render with current template" onClick={() => onExport("searchable", "rerender")} />
{invoice.status === "issued" ? (
<OverflowMenuItem
itemText={payments.length > 0 ? "Cancel invoice (delete payments first)" : "Cancel invoice"}
hasDivider
isDelete
disabled={payments.length > 0}
onClick={() => {
setReason("");
setCancelOpen(true);
}}
/>
) : null}
</OverflowMenu>
</div>
<ExportFeedback exp={exp} />
<div className="voiced-detail__grid">
<div className="voiced-detail__main voiced-stack">
<div className="voiced-detail__tiles">
<Tile>
<p className="voiced-muted">Total</p>
<p className="voiced-detail__amount">{formatPaise(total)}</p>
</Tile>
<Tile>
<p className="voiced-muted">Received</p>
<p className="voiced-detail__amount">{formatPaise(invoice.paidPaise ?? 0)}</p>
</Tile>
<Tile>
<p className="voiced-muted">TDS deducted</p>
<p className="voiced-detail__amount">{formatPaise(invoice.tdsPaise ?? 0)}</p>
</Tile>
<Tile>
<p className="voiced-muted">Balance</p>
<p className="voiced-detail__amount">{formatPaise(balance)}</p>
</Tile>
</div>
<section aria-labelledby="detail-parties">
<h2 id="detail-parties" className="voiced-detail__h2">
Parties
</h2>
<div className="voiced-detail__parties">
<dl>
<Field label="Billed to">{invoice.clientName}</Field>
<Field label="Address">
<span className="voiced-detail__pre">{invoice.clientAddress}</span>
</Field>
<Field label="GSTIN">{invoice.clientGstin}</Field>
<Field label="PO number">{invoice.poNumber}</Field>
<Field label="Place of supply">{invoice.placeOfSupplyStateCode ? stateName(invoice.placeOfSupplyStateCode) : ""}</Field>
</dl>
<dl>
<Field label="Issued by">{vendor.vendorName}</Field>
<Field label="Address">
<span className="voiced-detail__pre">{vendor.vendorAddress}</span>
</Field>
<Field label="GSTIN">{vendor.vendorGstin}</Field>
<Field label="PAN">{vendor.vendorPan}</Field>
</dl>
</div>
</section>
<section aria-labelledby="detail-items">
<h2 id="detail-items" className="voiced-detail__h2">
Items
</h2>
<TableContainer>
<Table aria-label="Invoice items" size="sm">
<TableHead>
<TableRow>
<TableHeader>Description</TableHeader>
<TableHeader>HSN/SAC</TableHeader>
<TableHeader className="voiced-num">Amount</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{invoice.items.map((item, i) => (
<TableRow key={item.id ?? i}>
<TableCell>
<span className="voiced-detail__pre">{item.description}</span>
{item.mode === "rate" ? (
<div className="voiced-muted">
{item.quantity} {item.unit} × ₹{formatAmount(item.rate)}
</div>
) : null}
</TableCell>
<TableCell>{item.hsnSac || "—"}</TableCell>
<TableCell className="voiced-num">₹{formatAmount(item.amount)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<dl className="voiced-detail__totals">
<div>
<dt>Subtotal</dt>
<dd>₹{formatAmount(invoice.subtotal)}</dd>
</div>
{invoice.discount > 0 ? (
<div>
<dt>Discount</dt>
<dd>−₹{formatAmount(invoice.discount)}</dd>
</div>
) : null}
{invoice.taxType === "cgst_sgst" ? (
<>
<div>
<dt>CGST @ {half}%</dt>
<dd>₹{formatAmount(invoice.cgstAmount)}</dd>
</div>
<div>
<dt>SGST @ {half}%</dt>
<dd>₹{formatAmount(invoice.sgstAmount)}</dd>
</div>
</>
) : null}
{invoice.taxType === "igst" ? (
<div>
<dt>IGST @ {invoice.taxRate}%</dt>
<dd>₹{formatAmount(invoice.igstAmount)}</dd>
</div>
) : null}
<div className="voiced-detail__grand">
<dt>Total</dt>
<dd>₹{formatAmount(invoice.total)}</dd>
</div>
</dl>
{invoice.amountInWords ? <p className="voiced-muted">{invoice.amountInWords}</p> : null}
{invoice.notes ? <p className="voiced-detail__pre voiced-muted">{invoice.notes}</p> : null}
</section>
<section aria-labelledby="detail-payments">
<h2 id="detail-payments" className="voiced-detail__h2">
Payments
</h2>
{payments.length === 0 ? (
<p className="voiced-muted">No payments recorded.</p>
) : (
<TableContainer>
<Table aria-label="Payments" size="sm">
<TableHead>
<TableRow>
<TableHeader>Date</TableHeader>
<TableHeader>Mode</TableHeader>
<TableHeader>Reference</TableHeader>
<TableHeader className="voiced-num">Received</TableHeader>
<TableHeader className="voiced-num">TDS</TableHeader>
<TableHeader>
<span className="cds--assistive-text">Actions</span>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{payments.map((p) => (
<TableRow key={p.id}>
<TableCell>{formatDate(p.paidOn)}</TableCell>
<TableCell>{paymentModeLabel(p.mode)}</TableCell>
<TableCell>
{p.reference || "—"}
{p.notes ? <div className="voiced-muted">{p.notes}</div> : null}
</TableCell>
<TableCell className="voiced-num">{formatPaise(p.amountPaise)}</TableCell>
<TableCell className="voiced-num">{formatPaise(p.tdsPaise)}</TableCell>
<TableCell>
<Button
kind="ghost"
size="sm"
hasIconOnly
renderIcon={TrashCan}
iconDescription={`Delete payment of ${formatPaise(p.amountPaise + p.tdsPaise)} on ${formatDate(p.paidOn)}`}
tooltipPosition="left"
onClick={() => setDeleteTarget(p)}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</section>
</div>
<aside className="voiced-detail__preview" aria-label="Archived PDF">
<h2 className="voiced-detail__h2">PDF as issued</h2>
{archive && !archive.archived ? (
<div className="voiced-detail__unarchived">
<p>Issued – not archived. The original PDF was never saved, so there is nothing to preview yet.</p>
<Button kind="primary" size="sm" onClick={() => void onArchive()} disabled={busy}>
{archiving ? "Working…" : "Render & archive"}
</Button>
</div>
) : (
<PdfPreview bytes={pdf} pages={0} issues={[]} rendering={archive === null && !pdfError} mode="worker" error={pdfError} />
)}
</aside>
</div>
<RecordPaymentModal
open={recordOpen}
invoiceId={invoice.id}
invoiceNumber={invoice.number}
balancePaise={balance}
onClose={() => setRecordOpen(false)}
onRecorded={(p) => {
setRecordOpen(false);
toast.success("Payment recorded", `${formatPaise(p.amountPaise + p.tdsPaise)} on ${formatDate(p.paidOn)}`);
void refresh();
}}
/>
<Modal
open={deleteTarget !== null}
danger
modalHeading="Delete this payment?"
primaryButtonText="Delete payment"
secondaryButtonText="Keep payment"
onRequestClose={() => setDeleteTarget(null)}
onRequestSubmit={() => void onDeletePayment()}
>
<p>
{deleteTarget
? `${formatPaise(deleteTarget.amountPaise + deleteTarget.tdsPaise)} (cash ${formatPaise(deleteTarget.amountPaise)}, TDS ${formatPaise(deleteTarget.tdsPaise)}) received on ${formatDate(deleteTarget.paidOn)}. The invoice balance goes back up by that amount.`
: ""}
</p>
</Modal>
<Modal
open={cancelOpen}
danger
modalHeading={`Cancel invoice ${invoice.number}?`}
primaryButtonText="Cancel invoice"
secondaryButtonText="Keep invoice"
onRequestClose={() => setCancelOpen(false)}
onRequestSubmit={() => void onCancelInvoice()}
>
<p style={{ marginBottom: "1rem" }}>The invoice stays in the list as cancelled and its number is not reused.</p>
<TextInput id="detailCancelReason" labelText="Reason (optional)" value={reason} onChange={(e) => setReason(e.target.value)} />
</Modal>
</div>
);
}
+267 -137
View File
@@ -1,11 +1,14 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Button,
InlineLoading,
InlineNotification,
Modal,
Link,
OverflowMenu,
OverflowMenuItem,
Pagination,
Search,
Select,
SelectItem,
Table,
TableBody,
TableCell,
@@ -14,175 +17,304 @@ import {
TableHeader,
TableRow,
Tag,
TextInput,
Tile,
} from "@carbon/react";
import { save } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import { formatAmount, formatDate } from "../lib/format";
import { createExportDeps } from "../lib/exportDeps";
import { renderAndArchive, type ExportSource } from "../lib/exportFlow";
import { exportHistory, type HistoryFormat } from "../lib/historyExport";
import { formatDate } from "../lib/format";
import { formatPaise } from "../lib/money";
import { statusBadge } from "../lib/payments";
import { currentFyStartYear, FY_MONTHS, fyLabel } from "../lib/fiscal";
import {
clientOptions,
filterInvoices,
fyOptions,
fySummary,
NO_FILTERS,
sortInvoices,
STATUS_FILTERS,
totalsOf,
type HistoryFilters,
type SortKey,
type SortSpec,
type StatusFilter,
} from "../lib/historyFilter";
import type { ExportMode } from "../lib/filename";
import type { ExportSource } from "../lib/exportFlow";
import { useInvoiceExport } from "../hooks/useInvoiceExport";
import ExportFeedback from "../components/ExportFeedback";
import { useToast } from "../components/ToastProvider";
import type { InvoiceSummary, Settings } from "../lib/types";
export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) {
interface Props {
settings: Settings;
active: boolean;
/** Opens the detail page of an invoice. */
onOpen: (id: number) => void;
}
const PAGE_SIZES = [25, 50, 100];
const COLUMNS: { key: SortKey; label: string; numeric?: boolean }[] = [
{ key: "number", label: "Number" },
{ key: "date", label: "Date" },
{ key: "client", label: "Client" },
{ key: "total", label: "Total", numeric: true },
{ key: "balance", label: "Balance", numeric: true },
{ key: "status", label: "Status" },
];
export default function InvoiceHistory({ settings, active, onOpen }: Props) {
const toast = useToast();
const [rows, setRows] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
const [reason, setReason] = useState("");
// Whether each invoice has an archived PDF; unknown (absent) until its status was read.
const [archived, setArchived] = useState<Record<number, boolean>>({});
const [notice, setNotice] = useState<string | null>(null);
const exp = useInvoiceExport({
settings,
bank: null,
onInvoiceUpdated: (inv) => setArchived((a) => ({ ...a, [inv.id]: Boolean(inv.archivedPdfSha256) })),
});
const [filters, setFilters] = useState<HistoryFilters>(NO_FILTERS);
const [sort, setSort] = useState<SortSpec>({ key: "date", dir: "desc" });
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(PAGE_SIZES[0]);
const exp = useInvoiceExport({ settings, bank: null });
const [exportingHistory, setExportingHistory] = useState(false);
const load = async () => {
try {
setRows(await api.listInvoices());
setError(null);
} catch (e) {
setError(String(e));
toast.error("Could not load invoices", String(e));
} finally {
setLoading(false);
}
};
// The view stays mounted, so refetch every time it is shown again.
// The view stays mounted, so refetch every time it is shown again (a payment may have been recorded).
useEffect(() => {
if (active) void load();
}, [active]);
// Read each invoice's archive status in the background; it is a local call per row.
useEffect(() => {
let stop = false;
void (async () => {
for (const row of rows) {
if (stop) return;
try {
const status = await api.archiveStatus(row.id);
if (!stop) setArchived((a) => ({ ...a, [row.id]: status.archived }));
} catch {
// Unknown stays unknown: the row just shows no tag.
}
}
})();
return () => {
stop = true;
const patch = (change: Partial<HistoryFilters>) => {
setFilters((f) => ({ ...f, ...change }));
setPage(1);
};
}, [rows]);
const currentFy = currentFyStartYear();
const tileFy = filters.fy === "all" ? currentFy : filters.fy;
const filtered = useMemo(() => filterInvoices(rows, filters), [rows, filters]);
const sorted = useMemo(() => sortInvoices(filtered, sort), [filtered, sort]);
const totals = useMemo(() => totalsOf(filtered), [filtered]);
const summary = useMemo(() => fySummary(rows, tileFy), [rows, tileFy]);
const years = useMemo(() => fyOptions(rows, currentFy), [rows, currentFy]);
const clients = useMemo(() => clientOptions(rows), [rows]);
const filtersActive = JSON.stringify(filters) !== JSON.stringify(NO_FILTERS);
const lastPage = Math.max(1, Math.ceil(sorted.length / pageSize));
const shown = sorted.slice((Math.min(page, lastPage) - 1) * pageSize, Math.min(page, lastPage) * pageSize);
const onSort = (key: SortKey) =>
setSort((s) => (s.key === key ? { key, dir: s.dir === "asc" ? "desc" : "asc" } : { key, dir: key === "date" || key === "total" || key === "balance" ? "desc" : "asc" }));
const onExport = async (id: number, mode: ExportMode, source: ExportSource) => {
setBusyId(id);
setError(null);
setNotice(null);
try {
// Fetched now, not at list time: the row summary does not carry the archive fields.
const invoice = await api.getInvoice(id);
await exp.start(invoice, mode, source);
} catch (e) {
setError(String(e));
toast.error("Export failed", String(e));
} finally {
setBusyId(null);
}
};
const onArchive = async (id: number) => {
setBusyId(id);
setError(null);
setNotice(null);
/** Exactly the rows the table is filtered to (every page, in the current sort order). */
const onExportHistory = async (format: HistoryFormat) => {
setExportingHistory(true);
try {
const invoice = await api.getInvoice(id);
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
if (outcome.archived) {
setArchived((a) => ({ ...a, [id]: true }));
setNotice(`Invoice ${invoice.number} is archived.`);
} else {
setError(`Invoice ${invoice.number}: ${outcome.error ?? "the PDF could not be archived."}`);
}
const done = await exportHistory(
{
listLedger: api.listInvoiceLedger,
getLastExportDir: api.getLastExportDir,
save: ({ defaultPath, format: fmt }) =>
save({
defaultPath,
filters: [fmt === "csv" ? { name: "CSV (Excel)", extensions: ["csv"] } : { name: "JSON", extensions: ["json"] }],
}),
writeExportFile: api.writeExportFile,
now: () => new Date(),
},
sorted,
filters,
format,
);
if (done) toast.success(`Exported ${done.count} invoices`, done.path);
} catch (e) {
setError(String(e));
toast.error("Could not export the invoices", String(e));
} finally {
setBusyId(null);
}
};
const onCancel = async () => {
if (!cancelTarget) return;
try {
await api.cancelInvoice(cancelTarget.id, reason);
setCancelTarget(null);
setReason("");
await load();
} catch (e) {
setCancelTarget(null);
setError(String(e));
setExportingHistory(false);
}
};
return (
<div className="voiced-page">
<h1 className="voiced-page__title">Invoices</h1>
<p className="voiced-page__subtitle">Every invoice you have issued, newest first.</p>
<p className="voiced-page__subtitle">Every invoice you have issued. Open one to see its PDF, record payments or export it.</p>
{error ? (
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
) : null}
{notice ? <InlineNotification kind="success" lowContrast title={notice} onCloseButtonClick={() => setNotice(null)} /> : null}
<ExportFeedback exp={exp} />
{loading ? (
<InlineLoading description="Loading invoices" />
) : rows.length === 0 ? (
<div className="voiced-empty">No invoices yet. Create one from “New invoice”.</div>
) : (
<>
<section aria-label={`Summary for ${fyLabel(tileFy)}`}>
<h2 className="voiced-history__fy">{fyLabel(tileFy)}</h2>
<div className="voiced-history__tiles">
<Tile>
<p className="voiced-muted">Invoiced</p>
<p className="voiced-history__amount">{formatPaise(summary.invoicedPaise)}</p>
<p className="voiced-muted">{summary.count} invoices</p>
</Tile>
<Tile>
<p className="voiced-muted">Received</p>
<p className="voiced-history__amount">{formatPaise(summary.receivedPaise)}</p>
</Tile>
<Tile>
<p className="voiced-muted">TDS deducted</p>
<p className="voiced-history__amount">{formatPaise(summary.tdsPaise)}</p>
</Tile>
<Tile>
<p className="voiced-muted">Outstanding</p>
<p className="voiced-history__amount">{formatPaise(summary.outstandingPaise)}</p>
</Tile>
<Tile className={summary.overdueCount > 0 ? "voiced-history__tile--overdue" : undefined}>
<p className="voiced-muted">Overdue</p>
<p className="voiced-history__amount">{formatPaise(summary.overduePaise)}</p>
<p className="voiced-muted">{summary.overdueCount} invoices</p>
</Tile>
</div>
</section>
<div className="voiced-history__filters" role="search">
<Search
id="historySearch"
size="md"
labelText="Search invoices"
placeholder="Number, client or PO number"
value={filters.search}
onChange={(e) => patch({ search: e.target.value })}
onClear={() => patch({ search: "" })}
/>
<Select id="historyStatus" size="md" labelText="Status" value={filters.status} onChange={(e) => patch({ status: e.target.value as StatusFilter })}>
{STATUS_FILTERS.map((s) => (
<SelectItem key={s.id} value={s.id} text={s.label} />
))}
</Select>
<Select
id="historyFy"
size="md"
labelText="Financial year"
value={String(filters.fy)}
onChange={(e) => patch({ fy: e.target.value === "all" ? "all" : Number(e.target.value) })}
>
<SelectItem value="all" text="All years" />
{years.map((y) => (
<SelectItem key={y} value={String(y)} text={fyLabel(y)} />
))}
</Select>
<Select
id="historyMonth"
size="md"
labelText="Month"
value={String(filters.month)}
onChange={(e) => patch({ month: e.target.value === "all" ? "all" : Number(e.target.value) })}
>
<SelectItem value="all" text="All months" />
{FY_MONTHS.map((m) => (
<SelectItem key={m.month} value={String(m.month)} text={m.label} />
))}
</Select>
<Select id="historyClient" size="md" labelText="Client" value={filters.client} onChange={(e) => patch({ client: e.target.value })}>
<SelectItem value="all" text="All clients" />
{clients.map((c) => (
<SelectItem key={c} value={c} text={c} />
))}
</Select>
{filtersActive ? (
<Button kind="ghost" size="md" onClick={() => patch({ ...NO_FILTERS })}>
Clear filters
</Button>
) : null}
</div>
<div className="voiced-history__export">
<Button kind="tertiary" size="md" disabled={sorted.length === 0 || exportingHistory} onClick={() => void onExportHistory("csv")}>
Export CSV
</Button>
<Button kind="ghost" size="md" disabled={sorted.length === 0 || exportingHistory} onClick={() => void onExportHistory("json")}>
Export JSON
</Button>
<span className="voiced-muted">
{filtersActive ? `The ${sorted.length} filtered invoices` : `All ${sorted.length} invoices`}, for your accountant
</span>
</div>
{sorted.length === 0 ? (
<div className="voiced-empty">No invoices match these filters.</div>
) : (
<TableContainer>
<Table aria-label="Invoices">
<TableHead>
<TableRow>
<TableHeader>Number</TableHeader>
<TableHeader>Date</TableHeader>
<TableHeader>Client</TableHeader>
<TableHeader>Total</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Actions</TableHeader>
{COLUMNS.map((c) => (
<TableHeader
key={c.key}
isSortable
isSortHeader={sort.key === c.key}
sortDirection={sort.key === c.key ? (sort.dir === "asc" ? "ASC" : "DESC") : "NONE"}
onClick={() => onSort(c.key)}
className={c.numeric ? "voiced-num" : undefined}
>
{c.label}
</TableHeader>
))}
<TableHeader>
<span className="cds--assistive-text">Actions</span>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.number}</TableCell>
{shown.map((row) => {
const badge = statusBadge(row.status, row.paymentStatus);
return (
<TableRow key={row.id} className="voiced-history__row" onClick={() => onOpen(row.id)}>
<TableCell>
<Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onOpen(row.id);
}}
>
{row.number}
</Link>
</TableCell>
<TableCell>{formatDate(row.invoiceDate)}</TableCell>
<TableCell>{row.clientName || "—"}</TableCell>
<TableCell>₹{formatAmount(row.total)}</TableCell>
<TableCell className="voiced-num">{formatPaise(row.totalPaise)}</TableCell>
<TableCell className="voiced-num">{row.status === "cancelled" ? "—" : formatPaise(row.balancePaise)}</TableCell>
<TableCell>
<div className="voiced-header-action">
{row.status === "cancelled" ? (
<Tag type="red" size="sm">
Cancelled
<Tag type={badge.type} size="sm">
{badge.label}
</Tag>
) : (
row.status
)}
{archived[row.id] === false ? (
<Tag type="warm-gray" size="sm">
Not archived
{row.status === "issued" && row.paymentStatus === "overdue" && row.paidPaise + row.tdsPaise > 0 ? (
<Tag type="blue" size="sm">
Partly paid
</Tag>
) : null}
</div>
</TableCell>
<TableCell>
<div className="voiced-header-action">
{archived[row.id] === false ? (
<Button kind="ghost" size="sm" onClick={() => void onArchive(row.id)} disabled={busyId !== null || exp.busy}>
{busyId === row.id ? "Working…" : "Render & archive"}
</Button>
) : null}
<TableCell onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<OverflowMenu
aria-label={`Actions for invoice ${row.number}`}
iconDescription="Actions"
@@ -190,49 +322,47 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
size="sm"
disabled={busyId !== null || exp.busy}
>
<OverflowMenuItem itemText="Open" onClick={() => onOpen(row.id)} />
<OverflowMenuItem itemText="Export PDF (original as issued)" onClick={() => void onExport(row.id, "searchable", "original")} />
<OverflowMenuItem itemText="Export flattened PDF" onClick={() => void onExport(row.id, "flattened", "original")} />
<OverflowMenuItem itemText="Re-render with current template" onClick={() => void onExport(row.id, "searchable", "rerender")} />
{row.status === "issued" ? (
<OverflowMenuItem
itemText="Cancel invoice"
hasDivider
isDelete
onClick={() => {
setReason("");
setCancelTarget(row);
}}
/>
) : null}
</OverflowMenu>
</div>
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
<div className="voiced-history__totals" role="status" aria-label="Totals for the filtered invoices">
<span className="voiced-muted">
{filtersActive ? "Filtered" : "All"}: {totals.count} invoices
</span>
<span>
Invoiced <strong>{formatPaise(totals.invoicedPaise)}</strong>
</span>
<span>
Received <strong>{formatPaise(totals.receivedPaise)}</strong>
</span>
<span>
TDS <strong>{formatPaise(totals.tdsPaise)}</strong>
</span>
<span>
Outstanding <strong>{formatPaise(totals.outstandingPaise)}</strong>
</span>
</div>
<Pagination
page={Math.min(page, lastPage)}
pageSize={pageSize}
pageSizes={PAGE_SIZES}
totalItems={sorted.length}
onChange={({ page: p, pageSize: s }: { page: number; pageSize: number }) => {
setPage(s !== pageSize ? 1 : p);
setPageSize(s);
}}
/>
</TableContainer>
)}
<Modal
open={cancelTarget !== null}
danger
modalHeading={`Cancel invoice ${cancelTarget?.number ?? ""}?`}
primaryButtonText="Cancel invoice"
secondaryButtonText="Keep invoice"
onRequestClose={() => setCancelTarget(null)}
onRequestSubmit={onCancel}
>
<p style={{ marginBottom: "1rem" }}>
The invoice stays in the list as cancelled and its number is not reused.
</p>
<TextInput
id="cancelReason"
labelText="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</Modal>
</>
)}
</div>
);
}
+99 -21
View File
@@ -2,6 +2,7 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } fro
import {
ActionableNotification,
Button,
ComboBox,
Column,
Dropdown,
Grid,
@@ -37,6 +38,7 @@ import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
import type {
BankAccount,
Client,
ItemPreset,
DraftSummary,
Invoice,
InvoiceInput,
@@ -64,8 +66,13 @@ import { issueAndArchive, renderAndArchive } from "../lib/exportFlow";
import { loadExportMode } from "../lib/exportMode";
import type { ExportMode } from "../lib/filename";
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
import { useUserFonts } from "../hooks/useUserFonts";
import { withUserFonts } from "../lib/fontImport";
import { ImagePicker } from "../components/ImagePicker";
import { loadLogoImages, type LogoImages } from "../lib/logo";
import ClientModal from "../components/ClientModal";
import { useToast } from "../components/ToastProvider";
import { itemFromPreset } from "../lib/presets";
// pdf.js is only needed once a preview exists, so it loads on demand.
const PdfPreview = lazy(() => import("../components/PdfPreview"));
@@ -77,6 +84,9 @@ interface NewInvoiceProps {
active: boolean;
/** Ask the shell to show this view (used by Ctrl+N from another tab). */
onActivate: () => void;
/** A draft to open as soon as the form is shown (set by "Duplicate" on the invoice page). */
openDraftId?: number | null;
onDraftOpened?: () => void;
}
const AUTOSAVE_MS = 1000;
@@ -87,7 +97,8 @@ const formatStamp = (iso: string) => {
return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`;
};
export default function NewInvoice({ settings, onSettingsChange, active, onActivate }: NewInvoiceProps) {
export default function NewInvoice({ settings, onSettingsChange, active, onActivate, openDraftId = null, onDraftOpened }: NewInvoiceProps) {
const toast = useToast();
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
const blankState = (): InvoiceFormState => {
const t = todayIso();
@@ -116,6 +127,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
const [number, setNumber] = useState("");
const [clients, setClients] = useState<Client[]>([]);
const [presets, setPresets] = useState<ItemPreset[]>([]);
const [clientModalOpen, setClientModalOpen] = useState(false);
const newClientButton = useRef<HTMLButtonElement | null>(null);
const [clientId, setClientId] = useState<number | null>(null);
const [clientName, setClientName] = useState("");
const [clientAddress, setClientAddress] = useState("");
@@ -186,7 +200,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
setDrafts(draftList);
setNoticeOpen(draftList.length > 0);
} catch (e) {
if (active) setError(String(e));
if (active) toast.error("Could not load invoice data", String(e));
}
})();
return () => {
@@ -194,6 +208,13 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
};
}, []);
// The view stays mounted, so pick up clients and presets edited on the Clients page when it is shown again.
useEffect(() => {
if (!active) return;
api.listClients().then(setClients).catch(() => {});
api.listItemPresets().then(setPresets).catch(() => {});
}, [active]);
useEffect(() => {
let active = true;
if (!settings.logoPath) {
@@ -331,6 +352,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
return next;
}, []);
// A failed autosave is reported once per failure streak (the message clears on the next success).
useEffect(() => {
if (draftError) toast.warning("Draft not saved", draftError);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draftError]);
useEffect(() => {
if (!unsaved) return;
const timer = setTimeout(() => {
@@ -354,7 +381,11 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
// Page setup for the invoice being edited; an issued invoice shows what was frozen at issue.
const [prefs, setPrefs] = useState<RenderPrefsV1>(() => parsePrefs(settings.renderPrefs));
useEffect(() => setPrefs(parsePrefs(settings.renderPrefs)), [settings.renderPrefs]);
const activePrefs = useMemo(() => (issued ? prefsForInvoice(issued, settings) : prefs), [issued, prefs, settings]);
// setupPrefs is the page setup as edited (and saved as the default); activePrefs adds the imported fonts the template
// draws, which is what renders and what is frozen on the invoice at issue. An issued invoice keeps its frozen fonts.
const userFonts = useUserFonts();
const setupPrefs = useMemo(() => (issued ? prefsForInvoice(issued, settings) : prefs), [issued, prefs, settings]);
const activePrefs = useMemo(() => (issued ? setupPrefs : withUserFonts(setupPrefs, userFonts)), [issued, setupPrefs, userFonts]);
// The preview renders the real PDF from a draft-status Invoice built from the form. It is skipped
// while this view is hidden (it stays mounted) so typing elsewhere never starts a render.
@@ -384,6 +415,15 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
setClientGstin(selected.gstin);
setPoNumber(selected.poNumber);
if (selected.stateCode) setPlaceOfSupply(selected.stateCode);
// Client defaults fill gaps only: typed notes are never overwritten.
if (selected.defaultNotes && !notes.trim()) setNotes(selected.defaultNotes);
if (selected.paymentTermsDays !== null) setDueDate(addDays(invoiceDate, selected.paymentTermsDays));
};
const addPreset = (preset: ItemPreset) => {
const line = itemFromPreset(preset, { hsnSac: settings.defaultHsnSac });
const isBlank = (i: InvoiceItem) => !i.description.trim() && !i.amount && !i.rate;
setItems((prev) => (prev.length === 1 && isBlank(prev[0]) ? [line] : [...prev, line]));
};
const applyState = (s: InvoiceFormState) => {
@@ -521,15 +561,23 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
void refreshNumber();
void refreshDrafts();
} catch (e) {
setError(String(e));
toast.error("Could not open the draft", String(e));
}
};
useEffect(() => {
if (openDraftId === null || !active) return;
onDraftOpened?.();
void resumeDraft(openDraftId);
// resumeDraft closes over fresh state each render; only a new request should trigger it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openDraftId, active]);
const removeDraft = async (id: number) => {
try {
await api.deleteDraft(id);
} catch (e) {
setError(String(e));
toast.error("Could not delete the draft", String(e));
}
await refreshDrafts();
};
@@ -590,7 +638,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
outcome = await issueAndArchive(input, activePrefs, createExportDeps({ settings, bank }));
} catch (e) {
issuedRef.current = false;
setError(String(e));
toast.error("Could not issue the invoice", String(e));
setSaving(false);
return;
}
@@ -675,20 +723,11 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
<InlineNotification
kind="error"
lowContrast
title="Could not save invoice"
title="Fix this before issuing"
subtitle={error}
onCloseButtonClick={() => setError(null)}
/>
) : null}
{draftError ? (
<InlineNotification
kind="warning"
lowContrast
title="Draft not saved"
subtitle={draftError}
onCloseButtonClick={() => setDraftError(null)}
/>
) : null}
{!locked && noticeOpen && !dirty && otherDrafts.length > 0 ? (
<ActionableNotification
inline
@@ -760,16 +799,30 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
</Column>
<Column sm={4} md={8} lg={8}>
<Dropdown
<ComboBox
id="savedClient"
disabled={locked}
titleText="Saved client (optional)"
label="Select a saved client to autofill"
placeholder="Type to search saved clients"
items={clients}
itemToString={(item) => (item ? item.name : "")}
itemToString={(item: Client | null) => (item ? item.name : "")}
selectedItem={clients.find((c) => c.id === clientId) ?? null}
onChange={({ selectedItem }) => selectedItem && applyClient(selectedItem)}
shouldFilterItem={({ item, inputValue }: { item: Client; inputValue: string | null }) => {
const q = (inputValue ?? "").trim().toLowerCase();
return !q || item.name.toLowerCase().includes(q) || item.gstin.toLowerCase().includes(q);
}}
onChange={({ selectedItem }: { selectedItem?: Client | null }) => selectedItem && applyClient(selectedItem)}
/>
<Button
ref={newClientButton}
kind="ghost"
size="sm"
renderIcon={Add}
disabled={locked}
onClick={() => setClientModalOpen(true)}
>
Create client
</Button>
</Column>
<Column sm={4} md={8} lg={8}>
<TextInput
@@ -839,6 +892,20 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
<div>
<h3 style={{ marginBottom: "0.75rem" }}>Line items</h3>
{presets.length > 0 ? (
<div style={{ maxWidth: "24rem", marginBottom: "0.75rem" }}>
<Dropdown
id="addFromPreset"
disabled={locked}
titleText="Add from preset"
label="Choose a saved item"
items={presets}
itemToString={(item: ItemPreset | null) => (item ? item.description : "")}
selectedItem={null}
onChange={({ selectedItem }: { selectedItem?: ItemPreset | null }) => selectedItem && addPreset(selectedItem)}
/>
</div>
) : null}
{items.map((item, index) => (
<div
key={index}
@@ -1038,7 +1105,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
<Column sm={4} md={8} lg={7}>
<div style={{ position: "sticky", top: "3rem" }}>
<h3 style={{ marginBottom: "0.75rem" }}>Preview</h3>
<PageSetupControls prefs={activePrefs} onChange={setPrefs} settings={settings} onSaved={onSettingsChange} disabled={locked} />
<PageSetupControls prefs={setupPrefs} onChange={setPrefs} settings={settings} onSaved={onSettingsChange} disabled={locked} />
<Suspense fallback={<InlineLoading description="Loading preview…" />}>
<PdfPreview
bytes={pdf.bytes}
@@ -1128,6 +1195,17 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
</TableBody>
</Table>
</Modal>
<ClientModal
open={clientModalOpen}
client={null}
launcherRef={newClientButton}
onClose={() => setClientModalOpen(false)}
onSaved={(saved) => {
setClientModalOpen(false);
applyClient(saved);
api.listClients().then(setClients).catch(() => {});
}}
/>
</>
);
}
+19 -27
View File
@@ -4,7 +4,6 @@ import {
Column,
Grid,
InlineLoading,
InlineNotification,
NumberInput,
Table,
TableBody,
@@ -19,8 +18,17 @@ import {
import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import type { InvoiceSeries } from "../lib/types";
import { useToast } from "../components/ToastProvider";
export default function SeriesSettings({ active: visible }: { active: boolean }) {
export default function SeriesSettings({
active: visible,
embedded = false,
}: {
active: boolean;
/** Rendered inside Settings: no page wrapper or title. */
embedded?: boolean;
}) {
const toast = useToast();
const [active, setActive] = useState<InvoiceSeries | null>(null);
const [all, setAll] = useState<InvoiceSeries[]>([]);
const [loading, setLoading] = useState(true);
@@ -28,8 +36,6 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
const [prefix, setPrefix] = useState(`INV/${new Date().getFullYear()}-`);
const [padding, setPadding] = useState(3);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const seeded = useRef(false);
@@ -44,9 +50,8 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
setPadding(current.padding);
seeded.current = true;
}
setError(null);
} catch (e) {
setError(String(e));
toast.error("Could not load series", String(e));
} finally {
setLoading(false);
}
@@ -65,47 +70,34 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
);
if (!ok) return;
setBusy(true);
setError(null);
setNotice(null);
try {
const created = await api.startNewSeries(prefix, padding);
setNotice(`New series started. Next invoice: ${created.nextInvoiceNumber}`);
toast.success("New series started", `Next invoice: ${created.nextInvoiceNumber}`);
await load();
} catch (e) {
setError(String(e));
toast.error("Could not start a new series", String(e));
} finally {
setBusy(false);
}
};
const pageClass = embedded ? undefined : "voiced-page";
if (loading) {
return (
<div className="voiced-page">
<div className={pageClass}>
<InlineLoading description="Loading series" />
</div>
);
}
return (
<div className="voiced-page">
<h1 className="voiced-page__title">Invoice series</h1>
<p className="voiced-page__subtitle">
<div className={pageClass}>
{embedded ? null : <h1 className="voiced-page__title">Invoice series</h1>}
<p className={embedded ? "voiced-muted" : "voiced-page__subtitle"}>
The active series drives the automatic invoice numbers. Start a new series to reset the counter.
</p>
{error ? (
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
) : null}
{notice ? (
<InlineNotification
kind="success"
lowContrast
title="Done"
subtitle={notice}
onCloseButtonClick={() => setNotice(null)}
/>
) : null}
<Grid narrow style={{ marginBottom: "2rem" }}>
<Column sm={4} md={4} lg={6}>
<TextInput