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}