//! A company's conventions, decisions and open items, journaled in the //! books (plan/served.md 5.2: `convention.*`, `decision.recorded`, //! `open_item.*`, and `entry.approved` for approval rules). //! //! Conventions are how this company keeps its books: posting templates //! ("an expense a member paid personally: Dr expense / Cr Due to //! Members"), policies, close steps, calendar items, notes. Decisions are //! the choices the owners made, with who made them and when. Open items //! are what is waiting on someone. All are company data that any agent //! reads through the server, not prose in a file beside the books. //! //! ```text //! GOAL: keep the company's way of working, its decisions and its loose //! ends in the books, sourced, so every agent everywhere sees them. //! SOURCE: plan/served.md I-3, 4.2 (books.start lists conventions), 5.2; //! DECISION (owner, 2026-10-16): light governance, approval //! rules exist for companies that want them and are off by //! default. //! INVARIANTS: //! 1. A convention's id is a slug ([a-z0-9-], 1 to 64, starting with a //! letter or digit), never reused: setting a live id revises it (its //! kind can't change), a retired id is refused. //! 2. A posting template has a debit and a credit line, each side's //! shares summing to exactly 1, each on an account in the chart that //! takes postings or on a slot ("{expense}") that names one when a //! draft is made from it; drafting from it makes lines on accounts in //! the chart that take postings, summing on each side to the amount //! exactly, each a whole number of minor units, none zero. //! 3. An approval rule names its approver and limits itself by accounts //! that take postings, an amount, or both; under //! `policy.approval_rules = enforce` an entry a rule covers posts only //! once that approver approved it as it stands (its fingerprint: a //! line added, a class or a party changed after an approval voids //! it), and an //! entry the server would post at once (an invoice, a bill, a //! payment, payroll, a remittance, a disposal) that a rule covers is //! refused: draft it instead. Reversals, closes, depreciation and //! amortization follow from entries already posted and are exempt. //! Under `off` (the default) rules never block anything. //! 4. Decision and open-item ids are allocated in order and journaled; //! a decision supersedes at most one live decision; an item resolves //! once. //! 5. Replay takes what was journaled (rules.md B-28): text limits and //! chart checks are for new commands only. //! 6. No text holds a full TIN (settings INVARIANT 4). //! ``` use crate::settings::{MAX_LONG_TEXT, MAX_TEXT, Source, line_text, long_text}; use crate::{EntryId, Side}; use chrono::NaiveDate; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; /// Longest convention or decision body. pub const MAX_BODY: usize = 20_000; /// What a convention is. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ConventionKind { /// How a kind of transaction is posted; drafts come from it /// (`books.draft_from_template`). PostingTemplate, /// Who must approve which entries (enforced only under /// `policy.approval_rules = enforce`). ApprovalRule, /// A rule of practice (numbering, file formats, capitalization). Policy, /// A step of the month-end close. CloseStep, /// Something due on a date every year (annual report, returns). Calendar, /// A mapping between these books and another system's names. Mapping, /// Anything else worth keeping. Note, } impl ConventionKind { /// Every kind, in order. pub const ALL: [ConventionKind; 7] = [ ConventionKind::PostingTemplate, ConventionKind::ApprovalRule, ConventionKind::Policy, ConventionKind::CloseStep, ConventionKind::Calendar, ConventionKind::Mapping, ConventionKind::Note, ]; /// Its wire name. pub fn key(self) -> &'static str { match self { ConventionKind::PostingTemplate => "posting_template", ConventionKind::ApprovalRule => "approval_rule", ConventionKind::Policy => "policy", ConventionKind::CloseStep => "close_step", ConventionKind::Calendar => "calendar", ConventionKind::Mapping => "mapping", ConventionKind::Note => "note", } } /// From its wire name. pub fn parse(s: &str) -> Option { Self::ALL.into_iter().find(|k| k.key() == s) } } /// One line of a posting template. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TemplateLine { /// The account's code, or a slot, `{name}` (lowercase letters, /// digits and `_`), that a draft names: `{expense}`. pub account: String, /// Debit or credit (`"debit"`, `"credit"`). #[serde(with = "side_word")] pub side: Side, /// Its share of the amount, as a decimal string ("1", "0.5"). pub share: String, } /// A side as a word, `"debit"` or `"credit"`. mod side_word { use crate::Side; use serde::{Deserialize, Deserializer, Serializer}; pub fn serialize(side: &Side, s: S) -> Result { s.serialize_str(match side { Side::Debit => "debit", Side::Credit => "credit", }) } pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { match String::deserialize(d)?.to_lowercase().as_str() { "debit" => Ok(Side::Debit), "credit" => Ok(Side::Credit), other => Err(serde::de::Error::custom(format!( "side {other:?}: debit or credit" ))), } } } /// A posting template's structure. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Template { /// The entry description drafts take unless given one. #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, /// The lines. pub lines: Vec, } /// An approval rule's structure. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ApprovalRule { /// Who approves: a name. pub approver: String, /// The rule covers entries with a line in one of these accounts /// (codes); empty: any account. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub accounts: Vec, /// The rule covers entries whose debits are strictly greater than /// this; unset: any amount. #[serde(default, skip_serializing_if = "Option::is_none")] pub amount_over: Option, } /// A convention being set: what `books.set_convention` takes. #[derive(Clone, Debug, PartialEq)] pub struct NewConvention { /// Its id (a slug). pub id: String, /// What it is. pub kind: ConventionKind, /// A short title. pub title: String, /// The convention in words (Markdown). pub body_md: String, /// Its structure: a [`Template`] for a posting template, an /// [`ApprovalRule`] for an approval rule; none for other kinds. pub structured: Option, /// Who decided it. pub source: Source, } /// Why and when something was retired or resolved. #[derive(Clone, Debug, PartialEq)] pub struct Closing { /// Why (a convention's retirement) or how (an item's resolution). pub text: String, /// Who decided. pub source: Source, /// The journal line. pub seq: u64, } /// A convention as the journal holds it. #[derive(Clone, Debug, PartialEq)] pub struct Convention { /// Its id. pub id: String, /// What it is. pub kind: ConventionKind, /// A short title. pub title: String, /// The convention in words. pub body_md: String, /// Its structure, if any. pub structured: Option, /// Who decided this revision. pub source: Source, /// The journal line of this revision. pub seq: u64, /// 1 for the first version, then 2, 3, ... pub revision: u32, /// Set once retired. pub retired: Option, } impl Convention { /// Its template, when it is a posting template. pub fn template(&self) -> Option