Skip to main content

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}