Skip to main content

backbone_inventory/infrastructure/persistence/
gl_voucher_repository.rs

1//! GL voucher repository — the hand-written SQL that reconciles a voucher's `posting_state` against
2//! the accounting ack, across ALL THREE posting voucher tables.
3//!
4//! Hand-authored and **user-owned**: this exact path is declared under `user_owned` in
5//! `metaphor.codegen.yaml`. It is not schema-derived and has no entity of its own.
6//!
7//! **Why this file exists (the one place the per-entity repository shape did not fit).** The
8//! GL-posting contract is uniform across vouchers: the physical movement (SLE + Bin) commits first,
9//! then the post is emitted and the voucher's `posting_state` goes pending→posted|failed. That same
10//! reconcile runs against `purchase_receipts`, `delivery_notes` or `stock_reconciliations` depending
11//! on which voucher is being posted — so it is nobody's entity SQL, and duplicating it three ways
12//! would be three places to get the idempotency guard wrong. The table name is chosen ONLY from the
13//! closed [`GlVoucher`] enum — never interpolated from a caller string — so the `format!`s below
14//! cannot carry untrusted input.
15
16use sqlx::PgPool;
17use uuid::Uuid;
18
19use backbone_orm::org_scope;
20
21/// Which voucher table a GL post reconciles against. A closed enum, so the interpolated table name
22/// can only ever be one of a few compile-time literals.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum GlVoucher {
25    PurchaseReceipt,
26    DeliveryNote,
27    StockReconciliation,
28    /// The landed-cost document's own post (Dr inventory valuation / Cr cost lines). Shares the
29    /// same posting_state reconcile columns as the three voucher doors. Never used with
30    /// [`Self::mark_reversal_posted`] — a validated landed cost cannot cancel (the reversal
31    /// pattern is a negative-amount landed cost), and its table carries no reversal columns.
32    LandedCost,
33}
34
35impl GlVoucher {
36    fn table(self) -> &'static str {
37        match self {
38            GlVoucher::PurchaseReceipt => "purchase_receipts",
39            GlVoucher::DeliveryNote => "delivery_notes",
40            GlVoucher::StockReconciliation => "stock_reconciliations",
41            GlVoucher::LandedCost => "landed_costs",
42        }
43    }
44}
45
46/// A voucher's GL settlement state, as the repost path reads it. Shared by every voucher header
47/// projection that carries it — the shape is identical across the three tables.
48pub struct GlSettlementState {
49    /// `posting_state::text` — "pending" | "posted" | "failed" | "not_applicable".
50    pub posting_state: String,
51    pub journal_id: Option<Uuid>,
52    pub accounting_post_id: Option<Uuid>,
53}
54
55/// The GL reconcile's SQL. Stateless: it holds no pool, because both methods run outside the
56/// movement's transaction — the physical movement is already committed by the time a post is
57/// emitted, and the reconcile is deliberately a separate write (the GL leg is eventually
58/// consistent).
59pub struct GlVoucherRepository;
60
61impl Default for GlVoucherRepository {
62    fn default() -> Self { Self::new() }
63}
64
65impl GlVoucherRepository {
66    pub fn new() -> Self { Self }
67
68    /// Reconcile a voucher from the accounting ack — the pending→posted transition.
69    ///
70    /// Guarded on `posting_state <> 'posted'` so a repost of an already-posted voucher cannot
71    /// overwrite the original journal's ids: accounting dedupes on
72    /// `(source_type, source_id, posting_type)` and hands back the ORIGINAL journal, and this guard
73    /// is what keeps the recorded ids stable through that.
74    ///
75    /// Runs `org_scope::execute_scoped` on the pool — the write rides the ambient org scope's
76    /// request-dedicated connection so it passes the composing decorator's fence (ADR-0029).
77    /// Undecorated (module tests) the UPDATE runs plain.
78    pub async fn mark_posted(
79        &self,
80        pool: &PgPool,
81        voucher: GlVoucher,
82        voucher_id: Uuid,
83        journal_id: Uuid,
84        post_id: Uuid,
85    ) -> Result<(), sqlx::Error> {
86        let sql = format!(
87            "UPDATE inventory.{} SET posting_state='posted'::gl_posting_state, journal_id=$2, accounting_post_id=$3, posted_at=now() WHERE id=$1 AND posting_state <> 'posted'::gl_posting_state",
88            voucher.table(),
89        );
90        org_scope::execute_scoped(
91            pool,
92            sqlx::query(&sql).bind(voucher_id).bind(journal_id).bind(post_id),
93        )
94        .await?;
95        Ok(())
96    }
97
98    /// Record a GL rejection. The physical movement is NOT rolled back — it really happened; the
99    /// voucher parks in `failed` and is re-drivable via the service's `repost_*` entrypoints.
100    ///
101    /// Same scoped-execute discipline as [`Self::mark_posted`].
102    pub async fn mark_failed(
103        &self,
104        pool: &PgPool,
105        voucher: GlVoucher,
106        voucher_id: Uuid,
107    ) -> Result<(), sqlx::Error> {
108        let sql = format!(
109            "UPDATE inventory.{} SET posting_state='failed'::gl_posting_state WHERE id=$1",
110            voucher.table(),
111        );
112        org_scope::execute_scoped(pool, sqlx::query(&sql).bind(voucher_id)).await?;
113        Ok(())
114    }
115
116    /// Record the GL REVERSAL post for a cancelled voucher (council 2026-07-29, #3).
117    ///
118    /// Distinct from [`Self::mark_posted`]: the original post is already `posted` with its own
119    /// `journal_id`/`accounting_post_id` (which stay intact — the original really happened). A
120    /// cancellation appends compensating SLEs and emits a `posting_type='reversal'` post; that
121    /// reversal's ids land in `reversal_journal_id`/`reversal_accounting_post_id`. There is no
122    /// `posting_state` guard here — the reversal is recorded regardless of the original's state.
123    pub async fn mark_reversal_posted(
124        &self,
125        pool: &PgPool,
126        voucher: GlVoucher,
127        voucher_id: Uuid,
128        journal_id: Uuid,
129        post_id: Uuid,
130    ) -> Result<(), sqlx::Error> {
131        let sql = format!(
132            "UPDATE inventory.{} SET reversal_journal_id=$2, reversal_accounting_post_id=$3 WHERE id=$1",
133            voucher.table(),
134        );
135        org_scope::execute_scoped(
136            pool,
137            sqlx::query(&sql).bind(voucher_id).bind(journal_id).bind(post_id),
138        )
139        .await?;
140        Ok(())
141    }
142
143    /// Retire a voucher's GL leg to `not_applicable` — the document genuinely posts no GL under
144    /// the current configuration. Two users: a landed-cost validation whose entire allocation
145    /// fell on already-consumed stock (Σ remaining-share δ = 0: nothing to revalue, nothing to
146    /// post), and a `valuation_policy='periodic'` posture whose real-time stock posts are
147    /// suppressed (the periodic closing flow — a later increment — owns those legs). Idempotent:
148    /// re-marking an already-retired voucher is a no-op. Same scoped-execute discipline as
149    /// [`Self::mark_posted`].
150    pub async fn mark_not_applicable(
151        &self,
152        pool: &PgPool,
153        voucher: GlVoucher,
154        voucher_id: Uuid,
155    ) -> Result<(), sqlx::Error> {
156        let sql = format!(
157            "UPDATE inventory.{} SET posting_state='not_applicable'::gl_posting_state WHERE id=$1",
158            voucher.table(),
159        );
160        org_scope::execute_scoped(pool, sqlx::query(&sql).bind(voucher_id)).await?;
161        Ok(())
162    }
163}