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}