Compare commits
4
Commits
45784266b5
...
2a00f4ef33
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a00f4ef33
|
||
|
|
54d2775a33
|
||
|
|
01d41c0daf
|
||
|
|
005a96055a
|
No files matched your search
@@ -2,3 +2,4 @@
|
||||
*.orig
|
||||
.DS_Store
|
||||
site/dist/
|
||||
.netlify/
|
||||
@@ -64,7 +64,7 @@ New books, one command (installs Rust and wanderbooks if needed, makes the
|
||||
books dir with the MCP server registered for it, and starts the agent):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/Acme
|
||||
curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/Acme
|
||||
```
|
||||
|
||||
Already installed: `wanderbooks new ~/Books/Acme`. In a books dir, `just`
|
||||
|
||||
@@ -270,20 +270,24 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
|
||||
theme::regular(12.5),
|
||||
p.muted,
|
||||
);
|
||||
painter.text(
|
||||
// Leave room for the debit, credit, and balance columns.
|
||||
let text_w = r.width() - 90.0 - amt * 3.0 - 12.0;
|
||||
widgets::clipped_text(
|
||||
&painter,
|
||||
egui::pos2(r.left() + 90.0, y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
desc,
|
||||
&desc,
|
||||
font.clone(),
|
||||
p.text,
|
||||
text_w,
|
||||
);
|
||||
if !sub.is_empty() {
|
||||
painter.text(
|
||||
widgets::clipped_text(
|
||||
&painter,
|
||||
egui::pos2(r.left() + 90.0, r.center().y + 9.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
sub,
|
||||
&sub,
|
||||
theme::regular(11.5),
|
||||
p.faint,
|
||||
text_w,
|
||||
);
|
||||
}
|
||||
for (i, v) in [d, c].iter().enumerate() {
|
||||
|
||||
@@ -368,12 +368,13 @@ fn coming_up(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
theme::regular(12.5),
|
||||
if late { p.negative } else { p.muted },
|
||||
);
|
||||
ui.painter().text(
|
||||
widgets::clipped_text(
|
||||
ui.painter(),
|
||||
r.left_center() + vec2(70.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&it.label,
|
||||
theme::regular(13.0),
|
||||
p.text,
|
||||
r.width() - 70.0 - 130.0,
|
||||
);
|
||||
let (txt, c) = if it.amount > Decimal::ZERO {
|
||||
(format!("+{}", fmt::money(it.amount)), p.positive)
|
||||
@@ -405,7 +406,11 @@ fn biggest_outflows(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let mut out: Vec<&crate::books::EntryInfo> = b
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.date > since && e.cash_effect < Decimal::ZERO)
|
||||
.filter(|e| {
|
||||
e.status == ledger_core::EntryStatus::Posted
|
||||
&& e.date > since
|
||||
&& e.cash_effect < Decimal::ZERO
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|e| e.cash_effect);
|
||||
out.truncate(8);
|
||||
@@ -431,12 +436,13 @@ fn biggest_outflows(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
theme::regular(12.5),
|
||||
p.muted,
|
||||
);
|
||||
ui.painter().text(
|
||||
widgets::clipped_text(
|
||||
ui.painter(),
|
||||
r.left_center() + vec2(70.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&e.description,
|
||||
theme::regular(13.0),
|
||||
p.text,
|
||||
r.width() - 70.0 - 130.0,
|
||||
);
|
||||
ui.painter().text(
|
||||
r.right_center() - vec2(6.0, 0.0),
|
||||
|
||||
@@ -267,20 +267,33 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
||||
for &i in &e.events {
|
||||
let line = &b.journal[i];
|
||||
let ev = super::journal::describe_any(b, line);
|
||||
ui.horizontal(|ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.label(RichText::new(ev.icon).color(p.muted));
|
||||
let r = widgets::link(ui, RichText::new(&ev.text).font(theme::regular(12.5)));
|
||||
if r.clicked() {
|
||||
shell.go(Route::JournalEvent(line.seq));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let r = ui.add(
|
||||
egui::Label::new(RichText::new(&ev.text).font(theme::regular(12.5)))
|
||||
.wrap()
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if r.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
if r.clicked() {
|
||||
shell.go(Route::JournalEvent(line.seq));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(format!("#{} · {}", line.seq, fmt::timestamp(&line.ts)))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
RichText::new(format!(
|
||||
"event {} · {}",
|
||||
line.seq,
|
||||
fmt::timestamp(&line.ts)
|
||||
))
|
||||
.font(theme::regular(11.0))
|
||||
.color(p.faint),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -401,12 +401,13 @@ fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
||||
egui::FontId::proportional(15.0),
|
||||
p.muted,
|
||||
);
|
||||
painter.text(
|
||||
widgets::clipped_text(
|
||||
&painter,
|
||||
egui::pos2(rect.left() + 80.0, y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&ev.text,
|
||||
theme::regular(13.0),
|
||||
p.text,
|
||||
rect.width() - 80.0 - 200.0,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(rect.right() - 8.0, y),
|
||||
|
||||
@@ -433,39 +433,51 @@ fn recent(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
}
|
||||
shown += 1;
|
||||
let fresh = line.seq > b.fresh_from_seq && since_update < 8.0;
|
||||
let row = egui::Frame::new()
|
||||
.fill(if fresh {
|
||||
p.new_highlight
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
})
|
||||
.corner_radius(egui::CornerRadius::same(6))
|
||||
.inner_margin(egui::Margin::symmetric(6, 3))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(ev.icon).size(15.0).color(p.muted));
|
||||
let r = widgets::link(
|
||||
ui,
|
||||
RichText::new(&ev.text).font(theme::regular(13.0)),
|
||||
);
|
||||
if r.clicked() {
|
||||
go =
|
||||
Some(ev.route.clone().unwrap_or(Route::JournalEvent(line.seq)));
|
||||
}
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new(fmt::timestamp(&line.ts))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
let _ = (row, i);
|
||||
// One painted row: icon, the event (cut with an ellipsis
|
||||
// before the timestamp column), the timestamp.
|
||||
let (r, resp) = ui.allocate_exact_size(
|
||||
egui::vec2(ui.available_width(), 30.0),
|
||||
egui::Sense::click(),
|
||||
);
|
||||
if fresh {
|
||||
ui.painter()
|
||||
.rect_filled(r, egui::CornerRadius::same(6), p.new_highlight);
|
||||
} else if resp.hovered() {
|
||||
ui.painter()
|
||||
.rect_filled(r, egui::CornerRadius::same(6), p.hover);
|
||||
}
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
let painter = ui.painter_at(r);
|
||||
let y = r.center().y;
|
||||
painter.text(
|
||||
egui::pos2(r.left() + 8.0, y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
ev.icon,
|
||||
egui::FontId::proportional(15.0),
|
||||
p.muted,
|
||||
);
|
||||
let ts =
|
||||
painter.layout_no_wrap(fmt::timestamp(&line.ts), theme::regular(11.5), p.faint);
|
||||
let ts_w = ts.size().x;
|
||||
painter.galley(
|
||||
egui::pos2(r.right() - 8.0 - ts_w, y - ts.size().y / 2.0),
|
||||
ts,
|
||||
p.faint,
|
||||
);
|
||||
widgets::clipped_text(
|
||||
&painter,
|
||||
egui::pos2(r.left() + 32.0, y),
|
||||
&ev.text,
|
||||
theme::regular(13.0),
|
||||
p.text,
|
||||
r.width() - 32.0 - ts_w - 28.0,
|
||||
);
|
||||
if resp.clicked() {
|
||||
go = Some(ev.route.clone().unwrap_or(Route::JournalEvent(line.seq)));
|
||||
}
|
||||
let _ = i;
|
||||
}
|
||||
if shown == 0 {
|
||||
widgets::empty(ui, icon::CLOCK, "Nothing yet", "");
|
||||
|
||||
@@ -289,3 +289,27 @@ pub fn segmented<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, options: &[(T,
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
/// Left-aligned single-line text, vertically centered at `pos`, cut with
|
||||
/// an ellipsis at `max_width` so it never runs into whatever is to its
|
||||
/// right (an amount, a date).
|
||||
pub fn clipped_text(
|
||||
painter: &egui::Painter,
|
||||
pos: egui::Pos2,
|
||||
text: &str,
|
||||
font: egui::FontId,
|
||||
color: Color32,
|
||||
max_width: f32,
|
||||
) {
|
||||
use egui::text::{LayoutJob, TextFormat, TextWrapping};
|
||||
let mut job = LayoutJob::single_section(text.to_string(), TextFormat::simple(font, color));
|
||||
job.wrap = TextWrapping {
|
||||
max_width: max_width.max(10.0),
|
||||
max_rows: 1,
|
||||
break_anywhere: true,
|
||||
overflow_character: Some('…'),
|
||||
};
|
||||
let galley = painter.layout_job(job);
|
||||
let y = pos.y - galley.size().y / 2.0;
|
||||
painter.galley(egui::pos2(pos.x, y), galley, color);
|
||||
}
|
||||
@@ -76,7 +76,13 @@ fn screenshots() {
|
||||
}
|
||||
let books = data::load_once(&source).unwrap();
|
||||
let mut harness = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 920.0))
|
||||
.with_size(egui::vec2(
|
||||
1440.0,
|
||||
std::env::var("SHOT_HEIGHT")
|
||||
.ok()
|
||||
.and_then(|h| h.parse().ok())
|
||||
.unwrap_or(920.0),
|
||||
))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, dark, route.clone()));
|
||||
harness.run_steps(4);
|
||||
let ctx = harness.ctx.clone();
|
||||
|
||||
@@ -101,10 +101,26 @@ impl ReceiptPolicy {
|
||||
|
||||
/// Human-readable policy summary for previews and errors.
|
||||
pub fn describe(&self) -> String {
|
||||
format!(
|
||||
"receipts required when entry debits are strictly greater than {}",
|
||||
self.threshold()
|
||||
)
|
||||
if self.threshold().is_zero() {
|
||||
"receipts required on every entry".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"receipts required when entry debits are strictly greater than {}",
|
||||
self.threshold()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The receipt rule, phrased for the agent's operating instructions.
|
||||
pub fn rule(&self) -> String {
|
||||
if self.threshold().is_zero() {
|
||||
"every entry needs at least one receipt".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"any entry moving more than ${} in debits needs at least one receipt",
|
||||
self.threshold().round_dp(2)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a description for split detection.
|
||||
|
||||
@@ -4051,7 +4051,11 @@ impl ServerHandler for BooksServer {
|
||||
.enable_resources()
|
||||
.build(),
|
||||
)
|
||||
.with_instructions(INSTRUCTIONS)
|
||||
// Tell the agent the policy these books actually use.
|
||||
.with_instructions(INSTRUCTIONS.replace(
|
||||
"any entry moving more than $25.00 in debits needs at least one receipt",
|
||||
&self.policy.rule(),
|
||||
))
|
||||
.with_server_info(Implementation::new(
|
||||
"wanderbooks",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
|
||||
@@ -1586,3 +1586,54 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
assert_eq!(v["blobs_verified"], 1);
|
||||
assert!(v["events"].as_u64().unwrap() > 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_receipts_always_required_when_threshold_is_zero() {
|
||||
let dir = test_dir("always-receipts");
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(&toml, "[books]\nreceipt_threshold = \"0.00\"\n").unwrap();
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&dir.join("books-data"),
|
||||
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||
);
|
||||
let init = mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
let instructions = init["result"]["instructions"].as_str().unwrap();
|
||||
assert!(
|
||||
instructions.contains("every entry needs at least one receipt"),
|
||||
"the agent is told the real policy: {instructions}"
|
||||
);
|
||||
assert!(!instructions.contains("$25.00"));
|
||||
mcp.notify("notifications/initialized");
|
||||
for (code, name, typ) in [("1000", "Bank", "asset"), ("6000", "Fees", "expense")] {
|
||||
mcp.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": typ}),
|
||||
);
|
||||
}
|
||||
let e = mcp.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-10-01", "description": "Bank fee"}),
|
||||
);
|
||||
let id = e["entry_id"].as_u64().unwrap();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
mcp.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": "5.00"}),
|
||||
);
|
||||
}
|
||||
let err = mcp.call_err(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
assert!(
|
||||
err.contains("receipt"),
|
||||
"a $5 entry needs a receipt too: {err}"
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
# wanderbooks installer. One line on a new machine:
|
||||
#
|
||||
# curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/Acme
|
||||
# curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/Acme
|
||||
#
|
||||
# Linux and macOS. On Windows, run it inside WSL2 (Ubuntu): the GUI opens as
|
||||
# a normal window on Windows 11.
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
set shell := ["bash", "-cu"]
|
||||
|
||||
# The Netlify CLI, fetched on demand.
|
||||
netlify := "npx --yes netlify-cli@27"
|
||||
|
||||
# List recipes
|
||||
default:
|
||||
@just --list
|
||||
@@ -41,6 +44,10 @@ site:
|
||||
cp site/index.html install.sh site/dist/
|
||||
cp -r site/assets site/dist/
|
||||
|
||||
# Publish the website: just publish user@host:/var/www/wanderbooks
|
||||
publish dest: site
|
||||
rsync -av --delete site/dist/ "{{dest}}"
|
||||
# Deploy the website to wanderbooks.ai (Netlify; `netlify login` once first)
|
||||
deploy: site
|
||||
{{netlify}} deploy --prod --dir site/dist --no-build
|
||||
|
||||
# Deploy a preview of the website and print its URL
|
||||
deploy-preview: site
|
||||
{{netlify}} deploy --dir site/dist --no-build
|
||||
@@ -0,0 +1,17 @@
|
||||
# wanderbooks.ai: the static site in site/, plus install.sh at the root.
|
||||
# `just site` builds the same thing locally; `just deploy` publishes it.
|
||||
[build]
|
||||
command = "rm -rf site/dist && mkdir -p site/dist && cp site/index.html install.sh site/dist/ && cp -r site/assets site/dist/"
|
||||
publish = "site/dist"
|
||||
|
||||
# curl | sh reads plain text; never let a CDN cache an old installer for long.
|
||||
[[headers]]
|
||||
for = "/install.sh"
|
||||
[headers.values]
|
||||
Content-Type = "text/plain; charset=utf-8"
|
||||
Cache-Control = "public, max-age=300"
|
||||
|
||||
[[headers]]
|
||||
for = "/assets/*"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=86400"
|
||||
+9
-3
@@ -5,6 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>wanderbooks — own your books, hire an AI bookkeeper</title>
|
||||
<meta name="description" content="Real double-entry bookkeeping in a folder you own, kept by an AI bookkeeper you talk to. No subscription, no lock-in, every number traceable to its receipt. An open-source alternative to QuickBooks Online.">
|
||||
<link rel="canonical" href="https://wanderbooks.ai/">
|
||||
<meta property="og:title" content="wanderbooks — own your books, hire an AI bookkeeper">
|
||||
<meta property="og:description" content="Real double-entry bookkeeping in a folder you own, kept by an AI bookkeeper you talk to. No subscription, no lock-in.">
|
||||
<meta property="og:url" content="https://wanderbooks.ai/">
|
||||
<meta property="og:image" content="https://wanderbooks.ai/assets/overview.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
@@ -117,7 +123,7 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
|
||||
<p class="lede">wanderbooks is real double-entry accounting that lives in <b>a folder you own</b>, kept by <b>an AI bookkeeper you talk to</b>. It finds your receipts, records and categorizes everything, fixes its mistakes the way an accountant would, and hands your CPA a clean package. No subscription. No lock-in. Every number traceable to its receipt.</p>
|
||||
<div class="install">
|
||||
<span class="prompt">$</span>
|
||||
<code id="cmd">curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/YourCompany</code>
|
||||
<code id="cmd">curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/YourCompany</code>
|
||||
<button class="copy" id="copy">Copy</button>
|
||||
</div>
|
||||
<p class="note">One line installs everything, makes your books, and starts your bookkeeper. Linux and macOS; Windows via WSL2. Free and open source; you bring <a href="https://claude.com/claude-code">Claude</a>.</p>
|
||||
@@ -250,10 +256,10 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
|
||||
</footer>
|
||||
</div>
|
||||
<script>
|
||||
// When served next to install.sh, point the one-liner at this site.
|
||||
// On a preview deploy, point the one-liner at the preview's own install.sh.
|
||||
(function () {
|
||||
var cmd = document.getElementById("cmd");
|
||||
if (location.protocol === "https:") {
|
||||
if (location.protocol === "https:" && location.hostname !== "wanderbooks.ai") {
|
||||
cmd.textContent = "curl -fsSL " + location.origin + "/install.sh | sh -s -- ~/Books/YourCompany";
|
||||
}
|
||||
document.getElementById("copy").addEventListener("click", function () {
|
||||
|
||||
Reference in new issue
Block a user