Skip to main content

backbone_accounting/domain/
reconcile_graph.rs

1//! Reconciliation-graph contract types (domain layer).
2//!
3//! These are the domain shapes shared by the `ReconcileGraphRepository` port
4//! (`domain/repositories/reconcile_graph_repository.rs`), the pure validation rules
5//! (`domain/services/reconcile_rules.rs`), and the application `ReconcileWriteService`.
6//! They mirror the wire contract in `backbone-gl-posting` (the `ReconcileSink` producers
7//! settle and clear through) — the host maps wire → domain at the seam.
8//!
9//! Model: `PartialReconcile` edges (debit↔credit over journal lines, partial amounts in
10//! COMPANY currency) + `FullReconcile` groups. Residual is COMPUTED (base line amount minus
11//! the partial amounts applied on either side), never stored; the matching number is a READ
12//! (stored `full_reconcile_id` for complete groups, a label derived from the component's
13//! minimum partial id otherwise). Unlinking is side-effecting — see the write service.
14//!
15//! Tenancy (ADR-0029): the module is tenant-agnostic. The `company_id` fields on these
16//! shapes are the documented legacy twin — kept so unstripped producers and consumers compile
17//! and run unchanged. The adapter echoes the ambient org scope's legacy company id into them
18//! (nil when no scope is bound); no statement keys on them, and cross-tenant isolation is the
19//! composing service's tenancy decorator.
20
21use chrono::{DateTime, NaiveDate, Utc};
22use rust_decimal::Decimal;
23use uuid::Uuid;
24
25/// Locates one side of an edge by producer identity (mirrors the wire `ReconcileLine`).
26/// Resolves to exactly one posted journal line on the shared reconcilable account.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct LineLocator {
29    /// Producer discriminator stamped on the journal line — e.g. "order" (sales invoice),
30    /// "expense" (purchase invoice), "payment", "settlement" (bank clearance), "manual".
31    pub source_type: String,
32    /// The producer document id the journal was posted from.
33    pub source_id: Uuid,
34    /// The reconcilable account both lines of the pair sit on.
35    pub account_id: Uuid,
36    /// `true` resolves to the reversal journal of the source (`journals.is_reversing`).
37    pub reversing: bool,
38}
39
40impl LineLocator {
41    pub fn new(source_type: &str, source_id: Uuid, account_id: Uuid) -> Self {
42        Self {
43            source_type: source_type.to_string(),
44            source_id,
45            account_id,
46            reversing: false,
47        }
48    }
49    pub fn reversing(mut self) -> Self {
50        self.reversing = true;
51        self
52    }
53}
54
55/// What created an edge — stamped on the partial for traceability.
56pub const ORIGIN_SETTLEMENT: &str = "settlement";
57pub const ORIGIN_CLEARING: &str = "clearing";
58pub const ORIGIN_MANUAL: &str = "manual";
59
60/// A loaded journal line with everything the reconciliation rules and write path need.
61/// `FOR UPDATE`-locked by the repository when loaded for writing.
62#[derive(Debug, Clone)]
63pub struct ReconcileLineSnapshot {
64    pub id: Uuid,
65    pub journal_id: Uuid,
66    /// The legacy tenancy twin echo (ADR-0029) — see the module-level note. Not read by any
67    /// guard or statement.
68    pub company_id: Uuid,
69    pub account_id: Uuid,
70    /// `accounts.account_subtype` (joined) — drives the settlement-dimension guard.
71    pub account_subtype: String,
72    pub party_type: Option<String>,
73    pub party_id: Option<Uuid>,
74    /// Document-currency amounts (one of the two is zero on a clean line).
75    pub debit_amount: Decimal,
76    pub credit_amount: Decimal,
77    pub currency: String,
78    /// Document → company currency rate at posting time.
79    pub exchange_rate: Decimal,
80    pub transaction_date: NaiveDate,
81    pub is_posted: bool,
82    pub journal_status: String,
83    pub journal_is_reversing: bool,
84    pub source_type: Option<String>,
85    pub source_id: Option<Uuid>,
86    pub is_reconciled: bool,
87    pub full_reconcile_id: Option<Uuid>,
88    /// Company-currency face amount (`base_debit_amount + base_credit_amount`); equals the
89    /// document-currency face while the books are single-currency.
90    pub base_amount: Decimal,
91}
92
93/// A partial edge to insert.
94#[derive(Debug, Clone)]
95pub struct NewPartial {
96    /// The legacy tenancy twin echo (ADR-0029) — carried for unstripped constructors; the
97    /// adapter writes no such column.
98    pub company_id: Uuid,
99    pub debit_move_id: Uuid,
100    pub credit_move_id: Uuid,
101    /// Company currency, strictly positive (the write service clamps before inserting).
102    pub amount: Decimal,
103    pub currency: String,
104    pub max_date: NaiveDate,
105    pub origin: String,
106    pub source_type: Option<String>,
107    pub source_id: Option<Uuid>,
108    pub metadata: serde_json::Value,
109}
110
111/// A loaded partial edge.
112#[derive(Debug, Clone)]
113pub struct PartialRow {
114    pub id: Uuid,
115    /// The legacy tenancy twin echo (ADR-0029) — see `NewPartial::company_id`.
116    pub company_id: Uuid,
117    pub debit_move_id: Uuid,
118    pub credit_move_id: Uuid,
119    pub amount: Decimal,
120    pub max_date: NaiveDate,
121    pub origin: String,
122    pub full_reconcile_id: Option<Uuid>,
123    pub exchange_move_id: Option<Uuid>,
124    pub source_type: Option<String>,
125    pub source_id: Option<Uuid>,
126}
127
128/// Account facts the guards need.
129#[derive(Debug, Clone)]
130pub struct AccountReconcileFlags {
131    pub is_reconcilable: bool,
132    pub subtype: String,
133}
134
135/// Outcome of locator resolution: exactly one line, or a typed failure.
136///
137/// The resolved snapshot is carried inline rather than boxed: resolving to one
138/// line is the ordinary outcome of every locator, so boxing would put an
139/// allocation on the path that always succeeds to save a few words on the two
140/// that do not.
141#[derive(Debug)]
142#[allow(clippy::large_enum_variant)]
143pub enum LocatorResolution {
144    One(ReconcileLineSnapshot),
145    NotFound,
146    Ambiguous(usize),
147}
148
149/// The matching-group READ: the connected component of a line, with the derived label.
150#[derive(Debug, Clone)]
151pub struct MatchingGroup {
152    /// "F-<uuid8>" for a stored full-reconcile group, "P-<uuid8>" derived from the
153    /// component's minimum partial id while the chain is still partial, "-" for a bare line.
154    pub label: String,
155    pub full_reconcile_id: Option<Uuid>,
156    pub line_ids: Vec<Uuid>,
157    pub partial_ids: Vec<Uuid>,
158    /// Per-line company-currency residual.
159    pub residuals: Vec<(Uuid, Decimal)>,
160}
161
162impl MatchingGroup {
163    pub fn residual_of(&self, line_id: Uuid) -> Option<Decimal> {
164        self.residuals
165            .iter()
166            .find(|(id, _)| *id == line_id)
167            .map(|(_, r)| *r)
168    }
169}
170
171/// One open AR/AP line for the party-aging read.
172#[derive(Debug, Clone)]
173pub struct PartyResidual {
174    pub line_id: Uuid,
175    pub journal_id: Uuid,
176    pub journal_number: String,
177    pub transaction_date: NaiveDate,
178    pub source_reference: Option<String>,
179    pub residual: Decimal,
180    pub currency: String,
181    pub is_reconciled: bool,
182}
183
184/// Typed reconciliation failure. `code()` is the stable error string asserted by the golden
185/// cases; carries no `sqlx` type (the domain stays persistence-agnostic).
186#[derive(Debug)]
187pub enum ReconcileError {
188    LineNotFound,
189    AmbiguousLocator(usize),
190    SameCompanyRequired,
191    SameAccountRequired,
192    CurrencyMismatch(String, String),
193    AccountNotReconcilable,
194    DirectionMismatch,
195    LineNotPosted,
196    PartyMismatch,
197    ExchangeAccountUnconfigured,
198    PeriodClosed,
199    Conflict(String),
200    Internal(String),
201}
202
203impl ReconcileError {
204    pub fn code(&self) -> &'static str {
205        match self {
206            ReconcileError::LineNotFound => "line_not_found",
207            ReconcileError::AmbiguousLocator(_) => "ambiguous_locator",
208            ReconcileError::SameCompanyRequired => "same_company_required",
209            ReconcileError::SameAccountRequired => "same_account_required",
210            ReconcileError::CurrencyMismatch(..) => "currency_mismatch",
211            ReconcileError::AccountNotReconcilable => "account_not_reconcilable",
212            ReconcileError::DirectionMismatch => "direction_mismatch",
213            ReconcileError::LineNotPosted => "line_not_posted",
214            ReconcileError::PartyMismatch => "party_mismatch",
215            ReconcileError::ExchangeAccountUnconfigured => "exchange_account_unconfigured",
216            ReconcileError::PeriodClosed => "period_closed",
217            ReconcileError::Conflict(_) => "conflict",
218            ReconcileError::Internal(_) => "internal_error",
219        }
220    }
221
222    /// HTTP status: missing line → 404, ambiguity/conflict → 409, guards → 422, internal → 500.
223    pub fn http_status(&self) -> u16 {
224        match self {
225            ReconcileError::LineNotFound => 404,
226            ReconcileError::AmbiguousLocator(_) | ReconcileError::Conflict(_) => 409,
227            ReconcileError::Internal(_) => 500,
228            _ => 422,
229        }
230    }
231}
232
233impl std::fmt::Display for ReconcileError {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        write!(f, "{}", self.code())
236    }
237}
238impl std::error::Error for ReconcileError {}
239
240/// Result of a reconcile verb. `partial_id` is `None` when the amount clamped to zero
241/// (on-account no-op — no edge exists). `full_reconcile_id` is set when this edge
242/// completed a group.
243#[derive(Debug, Clone)]
244pub struct EdgeOutcome {
245    pub partial_id: Option<Uuid>,
246    pub applied: Decimal,
247    pub full_reconcile_id: Option<Uuid>,
248}
249
250/// Request shape for the pair verbs (application layer).
251#[derive(Debug, Clone)]
252pub struct PairRequest {
253    pub debit: LineLocator,
254    pub credit: LineLocator,
255    pub amount: Decimal,
256    pub origin: String,
257    pub actor: Option<Uuid>,
258}
259
260/// The timestamp shape used for group stamping.
261pub type ReconciledAt = DateTime<Utc>;