1use 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
27pub const IMAGE: &str = "wanderbooks-gnucash-oracle";
29
30pub 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
44struct Account {
46 code: String,
47 kind: &'static str,
48 normal_debit: bool,
49}
50
51#[derive(Default)]
53pub struct GnuCash {
54 accounts: Vec<Account>,
55 entries: Vec<Value>,
56 event_entries: BTreeMap<usize, usize>,
58 events: usize,
59}
60
61impl GnuCash {
62 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 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 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}