Skip to main content

backbone_gl_posting/
reconcile.rs

1//! The reconciliation wire contract — the seam producers use to create and unlink
2//! debit↔credit reconciliation edges on accounting's journal lines, mirroring how
3//! [`crate::GlPostSink`] carries GL posts.
4//!
5//! - A caller identifies each side by **locator** (producer source + document id + the reconcilable
6//!   account both lines must share), never by journal-line id — producers stay blind to accounting
7//!   internals. The sink resolves a locator to exactly one posted line.
8//! - [`ReconcileSink::reconcile_pair_on`] is **connection-taking**: the edge commits atomically
9//!   with the caller's unit of work (settlement, clearing). All schemas co-locate in one database,
10//!   so a caller-held transaction may write accounting tables; the caller's company binding applies.
11//! - [`ReconcileSink::unreconcile_pair_on`] is **side-effecting** (the Odoo unlink contract): it
12//!   reverses the moves the partial generated (exchange difference today, cash-basis tax deferrals
13//!   when those land), dissolves or repairs full-reconcile groups, and only then removes the edge —
14//!   never a bare DELETE.
15//! - Amounts are clamped to the smaller residual on each side; an over-settlement therefore leaves
16//!   the unapplied remainder unreconciled on the credit line (the on-account credit).
17
18use rust_decimal::Decimal;
19use serde::{Deserialize, Serialize};
20use uuid::Uuid;
21
22/// Locates one side of a reconciliation edge by producer identity. Resolves to exactly one posted
23/// journal line on `account_id` — the reconcilable control account BOTH sides must share.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct ReconcileLine {
26    /// Producer discriminator stamped on the journal — e.g. "order" (sales invoice),
27    /// "expense" (purchase invoice), "payment", "settlement" (bank clearance), "manual".
28    pub source_type: String,
29    /// The producer document id the journal was posted from.
30    pub source_id: Uuid,
31    /// The reconcilable account both lines of the pair sit on.
32    pub account_id: Uuid,
33    /// `true` resolves to the reversal journal of the source (journals.is_reversing), not the original.
34    pub reversing: bool,
35}
36
37impl ReconcileLine {
38    pub fn new(source_type: &str, source_id: Uuid, account_id: Uuid) -> Self {
39        Self { source_type: source_type.to_string(), source_id, account_id, reversing: false }
40    }
41    pub fn reversing(mut self) -> Self {
42        self.reversing = true;
43        self
44    }
45}
46
47/// What created the edge — stamped on the partial for traceability.
48#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
49pub enum ReconcileOrigin {
50    /// A settlement applied an invoice receivable/payable against a payment.
51    Settlement,
52    /// A bank clearance matched a payment's clearing line against the clearance leg.
53    Clearing,
54    /// An accountant's manual match.
55    Manual,
56}
57
58/// Create (or grow) the debit↔credit edge between two located lines.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct ReconcilePairRequest {
61    pub company_id: Uuid,
62    pub debit: ReconcileLine,
63    pub credit: ReconcileLine,
64    /// Requested amount in company currency; the sink clamps to the smaller line residual.
65    pub amount: Decimal,
66    pub origin: ReconcileOrigin,
67}
68
69/// The resulting edge. `applied` is the post-clamp amount actually reconciled.
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
71pub struct ReconcileEdgeAck {
72    /// `None` when the requested amount clamped to zero — no edge exists, and the
73    /// unapplied remainder stays unreconciled on the credit line (the on-account credit).
74    pub partial_id: Option<Uuid>,
75    pub applied: Decimal,
76    /// Set when this edge completed the pair — every connected line reached zero residual.
77    pub full_reconcile_id: Option<Uuid>,
78}
79
80/// Unlink every partial between the located pair (side-effecting — see the module docs).
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
82pub struct UnreconcilePairRequest {
83    pub company_id: Uuid,
84    pub debit: ReconcileLine,
85    pub credit: ReconcileLine,
86}
87
88/// Why the sink refused (guard violation, unresolvable locator, unconfigured FX account …).
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
90pub struct ReconcileRejected {
91    pub code: String,
92    pub message: String,
93}
94
95/// The reconciliation seam. A composing service implements this over accounting's reconciliation
96/// write service; producers never depend on backbone-accounting — they reach the graph only
97/// through this port, exactly as they reach posting through [`crate::GlPostSink`].
98#[async_trait::async_trait]
99pub trait ReconcileSink: Send + Sync {
100    /// Create a partial edge riding the caller's transaction.
101    async fn reconcile_pair_on(
102        &self,
103        conn: &mut sqlx::PgConnection,
104        req: &ReconcilePairRequest,
105    ) -> Result<ReconcileEdgeAck, ReconcileRejected>;
106
107    /// Side-effecting unlink riding the caller's transaction: reverses generated moves,
108    /// repairs groups, removes the partials between the pair.
109    ///
110    /// **Contract: reversals are pair-complete.** The unlink removes EVERY partial between
111    /// the located pair, whatever their origin and amount — it is not scoped to a subset of
112    /// the pair's history. A producer must therefore only call this for a pair whose edges it
113    /// is undoing IN FULL: cancel events must carry the complete allocation set for each
114    /// affected pair (an all-or-nothing document reversal), never a partial subset — a
115    /// subset would restore the caller's cached bookkeeping by less than the graph reopens,
116    /// silently diverging the two. The void return reflects today's producers (each pair
117    /// carries one allocation, one edge); a future producer needing amount-scoped reversal
118    /// requires a richer ack first.
119    async fn unreconcile_pair_on(
120        &self,
121        conn: &mut sqlx::PgConnection,
122        req: &UnreconcilePairRequest,
123    ) -> Result<(), ReconcileRejected>;
124}