Skip to main content

bookspec_gnucash/
lib.rs

1//! GnuCash as a second bookspec engine (`plan/served.md` P13 step 5).
2//!
3//! The adapter keeps an example's chart and entries as the oracle's JSON
4//! books (`oracle/gnucash/books.py`: accounts, entries, as-of days) and
5//! asks GnuCash 5.10, in its pinned container, for each balance. GnuCash
6//! is a cross-check, never the authority (`docs/oracle.md`, "Who is
7//! right"): where it differs from an example, the difference is
8//! adjudicated from the sources and recorded, not conformed to.
9//!
10//! Only what GnuCash itself answers is claimed: the balance queries
11//! (`trial_balance`, `balances`), by the rules whose examples ask nothing
12//! else. GnuCash never refuses an entry (it books the difference to an
13//! `Imbalance` account), so a refused event is *accepted* here, as it
14//! would be by a GnuCash user, and the runner reports the difference.
15
16use std::collections::BTreeMap;
17use std::io::Write;
18use std::process::{Command, Stdio};
19use std::str::FromStr;
20
21use bookspec_checker::{
22    Area, ChangeSpec, Claims, Engine, Event, Query, Refusal, RuleId, Setup, fallback_refusal,
23};
24use rust_decimal::Decimal;
25use serde_json::{Value, json};
26
27/// The oracle container (`just oracle` builds it from `oracle/gnucash`).
28pub const IMAGE: &str = "wanderbooks-gnucash-oracle";
29
30/// The rules this adapter claims: those whose examples ask only for
31/// balances, which GnuCash answers itself.
32pub fn claimed() -> Vec<RuleId> {
33    vec![
34        RuleId::new(Area::Entry, 1),
35        RuleId::new(Area::Entry, 2),
36        RuleId::new(Area::Entry, 3),
37        RuleId::new(Area::Ledger, 1),
38        RuleId::new(Area::Ledger, 3),
39        RuleId::new(Area::Statement, 1),
40        RuleId::new(Area::Statement, 2),
41    ]
42}
43
44/// One account of the book handed to GnuCash.
45struct Account {
46    code: String,
47    kind: &'static str,
48    normal_debit: bool,
49}
50
51/// GnuCash, as bookspec's `Engine`.
52#[derive(Default)]
53pub struct GnuCash {
54    accounts: Vec<Account>,
55    entries: Vec<Value>,
56    /// Each event's entry, by event number (1-based), for reversals.
57    event_entries: BTreeMap<usize, usize>,
58    events: usize,
59}
60
61impl GnuCash {
62    /// An empty engine.
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    fn id(&self, code: &str) -> Option<usize> {
68        self.accounts
69            .iter()
70            .position(|a| a.code == code)
71            .map(|i| i + 1)
72    }
73
74    fn book(&self, as_of: &str) -> Value {
75        let accounts: Vec<Value> = self
76            .accounts
77            .iter()
78            .enumerate()
79            .map(|(i, a)| json!({"id": i + 1, "code": a.code, "name": a.code, "type": a.kind}))
80            .collect();
81        json!({"accounts": accounts, "entries": self.entries, "as_of": [as_of]})
82    }
83
84    /// GnuCash's own balance of every account as of a day: debits
85    /// positive, exact decimal strings, by account code.
86    fn balances(&self, as_of: &str) -> Result<BTreeMap<String, Decimal>, Refusal> {
87        let mut child = Command::new("docker")
88            .args(["run", "--rm", "-i", IMAGE])
89            .stdin(Stdio::piped())
90            .stdout(Stdio::piped())
91            .stderr(Stdio::null())
92            .spawn()
93            .map_err(|e| unavailable(format!("docker: {e}")))?;
94        let books = json!([self.book(as_of)]);
95        child
96            .stdin
97            .take()
98            .unwrap()
99            .write_all(books.to_string().as_bytes())
100            .map_err(|e| unavailable(format!("docker stdin: {e}")))?;
101        let out = child
102            .wait_with_output()
103            .map_err(|e| unavailable(format!("docker: {e}")))?;
104        if !out.status.success() {
105            return Err(unavailable(
106                "the GnuCash oracle failed; build it with `just oracle`".into(),
107            ));
108        }
109        let results: Value = serde_json::from_slice(&out.stdout)
110            .map_err(|e| unavailable(format!("oracle output: {e}")))?;
111        let at = &results[0]["balances"][as_of];
112        let mut map = BTreeMap::new();
113        for (i, a) in self.accounts.iter().enumerate() {
114            let v = at[(i + 1).to_string()]
115                .as_str()
116                .ok_or_else(|| unavailable(format!("no balance for {}", a.code)))?;
117            map.insert(
118                a.code.clone(),
119                Decimal::from_str(v).map_err(|e| unavailable(e.to_string()))?,
120            );
121        }
122        Ok(map)
123    }
124}
125
126fn unavailable(why: String) -> Refusal {
127    fallback_refusal(
128        RuleId::new(Area::Ledger, 1),
129        format!("the GnuCash oracle: {why}"),
130    )
131}
132
133fn not_supported(what: &str) -> Refusal {
134    fallback_refusal(
135        RuleId::new(Area::Ledger, 1),
136        format!("the GnuCash adapter does not take {what}"),
137    )
138}
139
140impl Engine for GnuCash {
141    fn load(&mut self, setup: &Setup) -> Result<(), Refusal> {
142        for a in &setup.accounts {
143            let (kind, normal_debit) = match a.kind.as_str() {
144                "asset" => ("Asset", true),
145                "liability" => ("Liability", false),
146                "equity" => ("Equity", false),
147                "revenue" => ("Revenue", false),
148                "expense" => ("Expense", true),
149                other => return Err(not_supported(&format!("account type {other}"))),
150            };
151            self.accounts.push(Account {
152                code: a.code.clone(),
153                kind,
154                normal_debit,
155            });
156        }
157        if setup.currency != "USD" {
158            return Err(not_supported("a currency other than USD"));
159        }
160        Ok(())
161    }
162
163    fn apply(&mut self, event: &Event) -> Result<(), Refusal> {
164        self.events += 1;
165        let n = self.events;
166        let date = event
167            .date
168            .clone()
169            .ok_or_else(|| not_supported("an undated event"))?;
170        let description = event.description.clone().unwrap_or_default();
171        if !event.lines.is_empty() {
172            let mut lines = Vec::new();
173            for l in &event.lines {
174                let account = self
175                    .id(&l.account)
176                    .ok_or_else(|| not_supported("an unknown account"))?;
177                lines.push(json!({"account": account, "side": l.side, "amount": l.amount}));
178            }
179            self.entries
180                .push(json!({"date": date, "description": description, "lines": lines}));
181            self.event_entries.insert(n, self.entries.len() - 1);
182            return Ok(());
183        }
184        if let Some(of) = event.reverses {
185            // GnuCash has no reversal link; a user reverses by writing the
186            // opposite transaction, which is what is done here.
187            let idx = *self
188                .event_entries
189                .get(&of)
190                .ok_or_else(|| not_supported("a reversal of an event that posted nothing"))?;
191            let original = self.entries[idx].clone();
192            let flipped: Vec<Value> = original["lines"]
193                .as_array()
194                .unwrap()
195                .iter()
196                .map(|l| {
197                    let side = if l["side"] == "debit" {
198                        "credit"
199                    } else {
200                        "debit"
201                    };
202                    json!({"account": l["account"], "side": side, "amount": l["amount"]})
203                })
204                .collect();
205            self.entries
206                .push(json!({"date": date, "description": description, "lines": flipped}));
207            self.event_entries.insert(n, self.entries.len() - 1);
208            return Ok(());
209        }
210        Err(not_supported("this kind of event"))
211    }
212
213    fn query(&self, q: &Query) -> Result<Value, Refusal> {
214        let as_of = q
215            .as_of
216            .clone()
217            .ok_or_else(|| not_supported("a query without as_of"))?;
218        match q.name.as_str() {
219            "trial_balance" | "balances" => {
220                let balances = self.balances(&as_of)?;
221                let mut out = serde_json::Map::new();
222                for a in &self.accounts {
223                    let b = balances[&a.code];
224                    let mut shown = if q.name == "balances" && !a.normal_debit {
225                        -b
226                    } else {
227                        b
228                    };
229                    if shown.is_zero() {
230                        shown = Decimal::ZERO;
231                    }
232                    if shown.scale() < 2 {
233                        shown.rescale(2);
234                    }
235                    out.insert(a.code.clone(), Value::String(shown.to_string()));
236                }
237                Ok(Value::Object(out))
238            }
239            other => Err(not_supported(&format!("the {other} query"))),
240        }
241    }
242
243    fn claims(&self) -> Claims {
244        Claims::new(claimed())
245    }
246
247    fn change(&mut self, _change: &ChangeSpec) -> Result<(), Refusal> {
248        Err(not_supported("a change"))
249    }
250}