backbone_accounting/application/service/chart_install_service.rs
1//! Chart install engine — turns a registered [`ChartDataset`] into real `accounts`
2//! rows, in one transaction.
3//!
4//! Posture (Odoo 19's chart model): datasets are DATA; there are no template tables.
5//! Installing writes ordinary, manager-editable account rows stamped with chart
6//! provenance columns (`chart_code`/`chart_version`). Re-installing the same chart is
7//! the update path: engine-owned fields (identity, classification, tree shape) are
8//! rewritten, user-owned fields (name, status, balances, bank/tax/budget settings)
9//! keep whatever the company last set. Manager renames survive; manager re-parents
10//! are reverted (reporting walks `path` — half-updated paths are worse than reverted
11//! ones); soft-deleted chart rows are resurrected.
12//!
13//! Refusals, all pre-write:
14//! - `chart_has_postings` — the company already has journal lines. Installing over
15//! live books is refused outright rather than partially switched.
16//! - `chart_account_number_conflict` — a non-deleted account already holds a number
17//! or code the dataset uses, and it is not the engine's own row (deterministic id +
18//! matching `chart_code`). Manual accounts and other charts' accounts are never
19//! absorbed; the error names the colliding numbers.
20//!
21//! Every row id is deterministic: `uuid5(NAMESPACE_URL, "account:{company}:{chart}:{code}")`.
22//! That makes re-install an idempotent upsert by construction and gives tax
23//! orchestration (which runs after install, keyed on account codes) a stable map.
24//!
25//! Tenancy (ADR-0029): the module carries no tenancy of its own — the composing service's
26//! tenancy decorator owns org scoping. The `company_id` lanes here are the documented legacy
27//! twin: the engine keeps feeding them into deterministic-id derivation, the persistence
28//! port, and the report so unstripped callers compile and run unchanged, but no statement
29//! keys on them. The install transaction relays the ambient org scope; an undecorated
30//! deployment gets an unfenced module.
31
32use crate::domain::chart_dataset::{validate_dataset, ChartDataset, DatasetError};
33use crate::domain::repositories::chart_install_repository::{
34 ChartAccountRow, ChartInstallRepository, UpsertOutcome,
35};
36use backbone_orm::org_scope;
37use sqlx::PgPool;
38use std::collections::{HashMap, HashSet};
39use std::sync::Arc;
40use uuid::Uuid;
41
42/// Summary of one registered chart, for the listing endpoint.
43#[derive(Debug, Clone, serde::Serialize)]
44pub struct ChartInfo {
45 pub code: String,
46 pub version: String,
47 pub name: String,
48 pub accounts: usize,
49}
50
51/// What an install did.
52#[derive(Debug, Clone, serde::Serialize)]
53pub struct InstallReport {
54 pub chart_code: String,
55 pub chart_version: String,
56 /// Rows freshly inserted.
57 pub accounts_installed: usize,
58 /// Own rows updated in place (re-install / version bump).
59 pub accounts_updated: usize,
60 /// Own rows that had been soft-deleted and were restored.
61 pub accounts_resurrected: usize,
62 /// `account_code -> account id` — the stable handle for follow-on wiring
63 /// (tax templates and their repartition accounts reference these).
64 pub account_ids: HashMap<String, Uuid>,
65}
66
67/// Install refusals and failures. Displays carry the operative detail (chart codes,
68/// colliding numbers, the underlying database error) — callers surface them verbatim.
69#[derive(Debug, thiserror::Error)]
70pub enum ChartInstallError {
71 #[error("chart_has_postings: company {1} already has journal entries; refusing to install chart '{0}' onto posted books")]
72 ChartHasPostings(String, Uuid),
73 #[error("chart_account_number_conflict: existing accounts hold numbers/codes the chart '{0}' uses but are not rows this chart's current codes install onto (manual rows, or this chart's rows under earlier numbering) — (number, code): {1:?}")]
74 AccountNumberConflict(String, Vec<(String, String)>),
75 #[error("unknown chart '{0}' (registered: {1:?})")]
76 UnknownChart(String, Vec<String>),
77 #[error("invalid dataset '{0}': {1}")]
78 InvalidDataset(String, #[source] DatasetError),
79 #[error("chart install database error: {0}")]
80 Db(#[from] anyhow::Error),
81 #[error("chart install transaction error: {0}")]
82 Tx(#[from] sqlx::Error),
83}
84
85/// The engine. Holds the pool, the persistence port, and the registry of datasets
86/// registered by the composing service.
87pub struct ChartInstallService {
88 repo: Arc<dyn ChartInstallRepository>,
89 pool: PgPool,
90 datasets: Vec<Arc<ChartDataset>>,
91}
92
93/// The company the chart's deterministic account ids are derived from, taken from the request's
94/// own scope. The derivation must stay stable per tenant, so the value is still needed — it just
95/// no longer arrives as an argument a caller could get wrong.
96fn ambient_company() -> Uuid {
97 backbone_orm::org_scope::current_org_scope()
98 .and_then(|s| s.legacy_company_id())
99 .unwrap_or_default()
100}
101
102impl ChartInstallService {
103 pub fn new(
104 repo: Arc<dyn ChartInstallRepository>,
105 pool: PgPool,
106 datasets: Vec<Arc<ChartDataset>>,
107 ) -> Self {
108 Self {
109 repo,
110 pool,
111 datasets,
112 }
113 }
114
115 /// Registered charts, in registration order.
116 pub fn list_charts(&self) -> Vec<ChartInfo> {
117 self.datasets
118 .iter()
119 .map(|ds| ChartInfo {
120 code: ds.code.clone(),
121 version: ds.version.clone(),
122 name: ds.name.clone(),
123 accounts: ds.accounts.len(),
124 })
125 .collect()
126 }
127
128 /// Deterministic id for one dataset row — stable across reinstalls and versions.
129 /// The company element is the legacy tenancy twin (ADR-0029): kept in the hash so
130 /// existing ids stay stable across the strip.
131 fn deterministic_id(company_id: Uuid, chart_code: &str, account_code: &str) -> Uuid {
132 Uuid::new_v5(
133 &Uuid::NAMESPACE_URL,
134 format!("account:{company_id}:{chart_code}:{account_code}").as_bytes(),
135 )
136 }
137
138 /// Resolve the tree facts the dataset does not carry: level, path, header/detail.
139 /// `is_header` is purely "has children in this dataset".
140 fn derive_rows(&self, company_id: Uuid, ds: &ChartDataset) -> Vec<ChartAccountRow> {
141 let parents: HashSet<&str> = ds
142 .accounts
143 .iter()
144 .filter_map(|a| a.parent_code.as_deref())
145 .collect();
146
147 // code -> (level, path) of already-derived ancestors (parents-first order
148 // guarantees the parent is present when the child is reached).
149 let mut derived: HashMap<&str, (i32, String)> = HashMap::new();
150 let mut rows = Vec::with_capacity(ds.accounts.len());
151
152 for def in &ds.accounts {
153 let (level, path, parent_id) = match def.parent_code.as_deref() {
154 None => (0, def.number.clone(), None),
155 Some(p) => {
156 let (plvl, ppath) = derived.get(p).expect("validated dataset: parent known");
157 (
158 plvl + 1,
159 format!("{ppath}/{}", def.number),
160 Some(Self::deterministic_id(company_id, &ds.code, p)),
161 )
162 }
163 };
164 let is_header = parents.contains(def.code.as_str());
165 derived.insert(def.code.as_str(), (level, path.clone()));
166 rows.push(ChartAccountRow {
167 id: Self::deterministic_id(company_id, &ds.code, &def.code),
168 company_id,
169 def: def.clone(),
170 parent_id,
171 level,
172 path,
173 is_header,
174 is_detail: !is_header,
175 chart_code: ds.code.clone(),
176 chart_version: ds.version.clone(),
177 });
178 }
179 rows
180 }
181
182 /// Install `chart_code` onto `company_id`. One transaction; the ambient org scope is
183 /// relayed onto it first so the composing service's tenancy fence accepts the writes
184 /// as the app role (ADR-0029 — no relay on an undecorated deployment).
185 pub async fn install(
186 &self,
187 chart_code: &str,
188 ) -> Result<InstallReport, ChartInstallError> {
189 // The chart is installed for the request's own tenant. The derivation below still needs
190 // the value — deterministic account ids are keyed on it — so it is read once here rather
191 // than taken from a caller who could name a different company than the session.
192 let company_id = ambient_company();
193 let ds = self
194 .datasets
195 .iter()
196 .find(|d| d.code == chart_code)
197 .ok_or_else(|| {
198 ChartInstallError::UnknownChart(
199 chart_code.to_string(),
200 self.datasets.iter().map(|d| d.code.clone()).collect(),
201 )
202 })?
203 .clone();
204
205 validate_dataset(&ds).map_err(|e| ChartInstallError::InvalidDataset(ds.code.clone(), e))?;
206
207 let rows = self.derive_rows(ambient_company(), &ds);
208
209 let mut tx = self.pool.begin().await?;
210 // Tenancy posture (ADR-0029): the module owns no scoping column — the composing
211 // service's tenancy decorator does. Relay the AMBIENT request scope onto this
212 // transaction when the caller bound one: the fence accepts the writes as the app
213 // role and the reads below see only this unit's rows. An undecorated deployment has
214 // no ambient scope and skips this entirely (unfenced by design).
215 if let Some(scope) = org_scope::current_org_scope() {
216 org_scope::bind_org_scope_on(&mut tx, &scope)
217 .await
218 .map_err(anyhow::Error::from)?;
219 backbone_orm::audit_context::relay_ambient_audit_on(&mut tx)
220 .await
221 .map_err(anyhow::Error::from)?;
222 }
223
224 if self.repo.company_has_postings(&mut tx).await? {
225 return Err(ChartInstallError::ChartHasPostings(
226 ds.code.clone(),
227 company_id,
228 ));
229 }
230
231 // Overlap gate — an existing non-deleted account colliding on number or code
232 // is fine ONLY if it is exactly our own row (deterministic id + our chart_code,
233 // which also covers version bumps). Everything else (manual rows, another
234 // chart's rows) is a named conflict, never absorbed.
235 let overlaps = self
236 .repo
237 .overlapping_accounts(&mut tx, &ds)
238 .await?;
239 let conflicts: Vec<(String, String)> = overlaps
240 .into_iter()
241 .filter(|o| {
242 let ours = rows.iter().any(|r| {
243 r.def.number == o.account_number
244 && r.id == o.id
245 && o.chart_code.as_deref() == Some(ds.code.as_str())
246 }) || rows.iter().any(|r| {
247 r.def.code == o.account_code
248 && r.id == o.id
249 && o.chart_code.as_deref() == Some(ds.code.as_str())
250 });
251 !ours
252 })
253 .map(|o| (o.account_number, o.account_code))
254 .collect();
255 if !conflicts.is_empty() {
256 return Err(ChartInstallError::AccountNumberConflict(
257 ds.code.clone(),
258 conflicts,
259 ));
260 }
261
262 let mut installed = 0usize;
263 let mut updated = 0usize;
264 let mut resurrected = 0usize;
265 let mut account_ids: HashMap<String, Uuid> = HashMap::with_capacity(rows.len());
266
267 // Dataset order is parents-first, so the parent_id FK is always satisfied.
268 for row in &rows {
269 match self.repo.upsert_account(&mut tx, row).await? {
270 UpsertOutcome::Inserted => installed += 1,
271 UpsertOutcome::Updated => updated += 1,
272 UpsertOutcome::Resurrected => resurrected += 1,
273 }
274 account_ids.insert(row.def.code.clone(), row.id);
275 }
276
277 tx.commit().await?;
278
279 Ok(InstallReport {
280 chart_code: ds.code.clone(),
281 chart_version: ds.version.clone(),
282 accounts_installed: installed,
283 accounts_updated: updated,
284 accounts_resurrected: resurrected,
285 account_ids,
286 })
287 }
288}