P1: the books keep how they are kept
Settings, conventions (posting templates, approval rules, policies, close steps, calendar, mappings, notes), decisions, open items and entry approvals are journal events (journal format 2). Books that keep their settings never read wanderbooks.toml or config.json; the server and the viewer take the vault key from --vault-key-file instead. books.import_settings moves a settings file in once, exactly as the server reads it. Approval rules, when enforced, gate every posting path. Replay refuses what it can't read as newer, never as corrupt. Lean: a record changes no figure. Mutation on the whole diff: every survivor killed by a test but two equivalent (docs/testing.md). The Company page says a late open item is overdue in words, not colour alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
e60ecc5317
commit
c587593d78
71 files changed
+12249
-461
No files matched your search
@@ -48,7 +48,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
ui,
|
||||
icon::BANK,
|
||||
"No cash accounts found",
|
||||
"Name an asset account \"checking\", \"savings\", or \"cash\", or set [books] cash_accounts in wanderbooks.toml.",
|
||||
"Name an asset account \"checking\", \"savings\", or \"cash\", or ask the agent to set the books' cash accounts.",
|
||||
)
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -38,6 +38,98 @@ fn kv(ui: &mut Ui, k: &str, v: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Who decided a setting and when, under its value.
|
||||
fn provenance(ui: &mut Ui, by: &str, on: chrono::NaiveDate) {
|
||||
let p = pal(ui.ctx());
|
||||
ui.label(
|
||||
RichText::new(format!("{by} · {}", fmt::date(on)))
|
||||
.font(theme::regular(11.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
|
||||
/// How the books are kept, as the owners decided: conventions, decisions,
|
||||
/// and what is open (each only when there is something to show).
|
||||
fn kept_cards(ui: &mut Ui, b: &Books) {
|
||||
let p = pal(ui.ctx());
|
||||
let kept = b.ledger.conventions();
|
||||
let conventions: Vec<_> = kept.conventions().filter(|c| c.retired.is_none()).collect();
|
||||
if !conventions.is_empty() {
|
||||
ui.add_space(12.0);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"How the books are kept",
|
||||
|_| {},
|
||||
|ui| {
|
||||
for c in &conventions {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(&c.title).font(theme::semibold(13.0)));
|
||||
widgets::pill(ui, &c.kind.key().replace('_', " "), p.accent, p.accent_soft);
|
||||
});
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(c.summary()).font(theme::regular(12.5)),
|
||||
&c.body_md,
|
||||
);
|
||||
provenance(ui, &c.source.decided_by, c.source.decided_on);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
let decisions: Vec<_> = kept
|
||||
.decisions()
|
||||
.filter(|d| d.superseded_by.is_none())
|
||||
.collect();
|
||||
if !decisions.is_empty() {
|
||||
ui.add_space(12.0);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Decisions",
|
||||
|_| {},
|
||||
|ui| {
|
||||
for d in decisions.iter().rev() {
|
||||
ui.label(RichText::new(&d.new.subject).font(theme::semibold(13.0)));
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(&d.new.decision_md).font(theme::regular(12.5)),
|
||||
&d.new.decision_md,
|
||||
);
|
||||
provenance(ui, &d.new.decided_by.join(" and "), d.new.decided_on);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
let open: Vec<_> = kept.open_items().filter(|i| i.resolved.is_none()).collect();
|
||||
if !open.is_empty() {
|
||||
ui.add_space(12.0);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Open items",
|
||||
|_| {},
|
||||
|ui| {
|
||||
for i in open {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(icon::FLAG).color(p.warning));
|
||||
ui.label(RichText::new(&i.new.title).font(theme::regular(13.0)));
|
||||
if let Some(due) = i.new.due {
|
||||
// Late says so in words, not only in colour.
|
||||
let late = due < b.today;
|
||||
let when = if late { "overdue since" } else { "due" };
|
||||
ui.label(
|
||||
RichText::new(format!("{when} {}", fmt::date(due)))
|
||||
.font(theme::regular(12.0))
|
||||
.color(if late { p.warning } else { p.muted }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
page(ui, "company", |ui| {
|
||||
@@ -54,14 +146,25 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
"Profile",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if c.profile.is_empty() {
|
||||
let kept = b.ledger.settings();
|
||||
// The currency is always known: facts are the rest.
|
||||
if c.profile.iter().all(|(k, _)| k == "base_currency") {
|
||||
ui.label(
|
||||
RichText::new("No [company] settings in wanderbooks.toml.")
|
||||
.color(p.muted),
|
||||
RichText::new(if kept.is_journaled() {
|
||||
"No company facts set yet: the agent records them as you give them."
|
||||
} else {
|
||||
"No company facts in the settings file yet: the agent records them as you give them."
|
||||
})
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
for (k, v) in &c.profile {
|
||||
kv(ui, &fmt::label(k), v);
|
||||
// Who set it, and when (settings kept in the
|
||||
// books carry their source).
|
||||
if let Some(r) = kept.get(&format!("company.{k}")) {
|
||||
provenance(ui, &r.source.decided_by, r.source.decided_on);
|
||||
}
|
||||
}
|
||||
kv(ui, "Accounting basis", &c.basis);
|
||||
kv(
|
||||
@@ -70,8 +173,21 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
c.tax_form.map(fmt::tax_form).unwrap_or("not set"),
|
||||
);
|
||||
kv(ui, "Receipt threshold", &fmt::dollars(b.receipt_threshold));
|
||||
if let Some(z) = &b.settings.timezone {
|
||||
kv(ui, "Time zone", z);
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(if kept.is_journaled() {
|
||||
"Kept in the books: the agent changes settings, with who decided and when."
|
||||
} else {
|
||||
"Read from the settings file: the agent can move it into the books."
|
||||
})
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
},
|
||||
);
|
||||
kept_cards(ui, b);
|
||||
}
|
||||
_ => {
|
||||
widgets::titled_card(
|
||||
|
||||
@@ -834,6 +834,75 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
format!("Cleared a line of #{}", entry_id.unwrap_or(0)),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
// The books' record of how they are kept (journal format 2).
|
||||
"settings.set" => {
|
||||
let keys: Vec<&str> = p["changes"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|c| c.get("key").and_then(|k| k.as_str()))
|
||||
.collect();
|
||||
let by = s(&p["source"], "decided_by");
|
||||
ev(
|
||||
icon::GEAR_SIX,
|
||||
if keys.is_empty() {
|
||||
format!("Settings moved into the books ({by})")
|
||||
} else {
|
||||
format!("Settings changed: {} ({by})", keys.join(", "))
|
||||
},
|
||||
Some(Route::Company),
|
||||
)
|
||||
}
|
||||
"convention.set" => ev(
|
||||
icon::SCROLL,
|
||||
format!(
|
||||
"Convention: {} ({}, {})",
|
||||
s(p, "title"),
|
||||
s(p, "kind").replace('_', " "),
|
||||
s(&p["source"], "decided_by")
|
||||
),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"convention.retired" => ev(
|
||||
icon::SCROLL,
|
||||
format!("Convention {} retired: {}", s(p, "id"), s(p, "reason")),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"decision.recorded" => ev(
|
||||
icon::GAVEL,
|
||||
format!(
|
||||
"Decision: {} ({}, {})",
|
||||
s(p, "subject"),
|
||||
p["decided_by"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" and "),
|
||||
s(p, "decided_on")
|
||||
),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"open_item.raised" => ev(
|
||||
icon::FLAG,
|
||||
format!("Open item #{id}: {}", s(p, "title")),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"open_item.resolved" => ev(
|
||||
icon::CHECK_CIRCLE,
|
||||
format!("Open item #{id} resolved: {}", s(p, "resolution")),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"entry.approved" => ev(
|
||||
icon::SEAL_CHECK,
|
||||
format!(
|
||||
"Entry #{} approved by {}",
|
||||
entry_id.unwrap_or(0),
|
||||
s(p, "approver")
|
||||
),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
other => ev(
|
||||
icon::DOT,
|
||||
other.to_string(),
|
||||
|
||||
Reference in new issue
Block a user