backbone_inventory/application/service/inventory_posture.rs
1//! The tenant's valuation posting posture (hand-authored, user-owned).
2//!
3//! An `impl InventoryWriteService` chunk: the single resolution of the valuation settings row
4//! ([`Posture`]) that every GL-posting surface consults — the four voucher-door envelope
5//! builders (receipt + delivery, submit + repost), the cancellation compensations, and the move
6//! engine's envelope builder. One helper, consulted everywhere, so the posture cannot drift
7//! between a door's submit and its repost/cancel legs.
8//!
9//! Tenancy (ADR-0029): the module is tenant-agnostic. The settings read rides the ambient org
10//! scope the composing service set per request — the module never names a tenant itself.
11//!
12//! What the posture switches:
13//!
14//! - **Anglo-saxon delivery debit** — when ON, the delivery door's DEBIT leg swaps
15//! `cogs_account_id` for the settings' `stock_interim_delivered_account_id` (the GR/IR-family
16//! interim leg; COGS recognition is deferred to the invoice side). The receipt side is
17//! unchanged in both postures — its `grir_account_id` header account IS the
18//! interim-received leg. There is NO second posting path: the posture only rewrites WHICH
19//! account a leg uses inside the same envelope.
20//! - **Periodic valuation policy** — suppresses real-time stock posts entirely: the doors
21//! build no envelope and retire the voucher to `not_applicable`; the engine's move legs do
22//! the same. The periodic closing flow (which would book those legs) is a later increment.
23//! - **Location valuation-account override** — the documented account-resolution chain per
24//! inventory leg: the location's `valuation_account_id` when set, else the door-header
25//! account. Smallest-first override: a location beats the header.
26//!
27//! An ABSENT settings row means the runtime defaults (`average` / perpetual / anglo off) —
28//! exactly the pre-overlay posting shapes — so rolling the module out is a no-op for
29//! tenants that never configure it.
30//!
31//! Per the module's 4-layer rule this file holds no SQL — the reads live on
32//! [`crate::infrastructure::persistence::ValuationOverlayRepository`].
33
34use rust_decimal::Decimal;
35use uuid::Uuid;
36
37use super::inventory_write_service::{InventoryError, InventoryWriteService};
38
39/// The resolved posting posture. Built from the settings row, or the runtime defaults when
40/// the calling tenant has no row.
41#[derive(Debug, Clone)]
42pub struct Posture {
43 /// `average` | `fifo` | `standard` — settings vocabulary. `average` (the moving-average
44 /// SLE engine) is the only costing engine shipped; nothing in the posting path branches
45 /// on this yet (a `standard` costing tenant is refused loudly at landed-cost validation,
46 /// which owns that seam).
47 pub cost_method: String,
48 /// `valuation_policy = 'periodic'`: real-time stock posts suppressed (doors + engine legs
49 /// `not_applicable`); the closing flow that books them is a later increment.
50 pub periodic: bool,
51 /// `anglo_saxon_accounting`: the delivery debit swaps COGS for the interim-delivered leg.
52 pub anglo_saxon: bool,
53 /// The interim-delivered account the delivery debits under the anglo-saxon posture.
54 /// Required loudly (fail-closed) when the posture is ON.
55 pub stock_interim_delivered_account_id: Option<Uuid>,
56}
57
58impl Default for Posture {
59 /// The runtime defaults — an absent settings row behaves exactly like the pre-overlay
60 /// module (moving average, perpetual real-time posting, no anglo-saxon swap).
61 fn default() -> Self {
62 Posture {
63 cost_method: "average".into(),
64 periodic: false,
65 anglo_saxon: false,
66 stock_interim_delivered_account_id: None,
67 }
68 }
69}
70
71impl InventoryWriteService {
72 /// Read the valuation settings row and resolve it into a [`Posture`]. `None` from the
73 /// repository (no row for the calling tenant) resolves to [`Posture::default`] — the
74 /// rollout no-op contract. The read rides the ambient org scope (ADR-0029).
75 pub(super) async fn posting_posture(
76 &self,
77 ) -> Result<Posture, InventoryError> {
78 let row = self.valuation_overlay.fetch_posture(&self.rpool()).await?;
79 Ok(match row {
80 None => Posture::default(),
81 Some(r) => Posture {
82 cost_method: r.cost_method,
83 periodic: r.valuation_policy == "periodic",
84 anglo_saxon: r.anglo_saxon_accounting,
85 stock_interim_delivered_account_id: r.stock_interim_delivered_account_id,
86 },
87 })
88 }
89
90 /// The same posture resolution on the CALLER'S connection — the variant the move engine
91 /// uses inside its open movement transaction (the ambient org scope is already bound on
92 /// the connection, so the fenced settings read is correct without a second transaction).
93 pub(super) async fn posting_posture_on(
94 &self,
95 conn: &mut sqlx::PgConnection,
96 ) -> Result<Posture, InventoryError> {
97 let row = self.valuation_overlay.fetch_posture_on(conn).await?;
98 Ok(match row {
99 None => Posture::default(),
100 Some(r) => Posture {
101 cost_method: r.cost_method,
102 periodic: r.valuation_policy == "periodic",
103 anglo_saxon: r.anglo_saxon_accounting,
104 stock_interim_delivered_account_id: r.stock_interim_delivered_account_id,
105 },
106 })
107 }
108
109 /// The delivery door's DEBIT leg under the posture: the anglo-saxon
110 /// interim-delivered account when the posture is ON, else the header COGS account
111 /// unchanged. Fail-closed: the posture ON with no interim account configured is
112 /// [`InventoryError::AngloPostureUnconfigured`] — no envelope, no partial post — because a
113 /// silent COGS fallback would book the interim leg to the wrong account on every
114 /// delivery. The SAME resolution is used by the delivery cancel compensation (the credit
115 /// side mirrors the debit the original post used), so submit/repost/cancel stay
116 /// symmetric.
117 pub(super) fn delivery_debit_account(
118 &self,
119 posture: &Posture,
120 cogs_account_id: Uuid,
121 ) -> Result<Uuid, InventoryError> {
122 if !posture.anglo_saxon {
123 return Ok(cogs_account_id);
124 }
125 posture
126 .stock_interim_delivered_account_id
127 .ok_or(InventoryError::AngloPostureUnconfigured)
128 }
129
130 /// The account-resolution chain for one inventory leg: the location's
131 /// `valuation_account_id` override when set, else the door-header account the leg would
132 /// use today. The documented chain, smallest-first — a location beats the header.
133 ///
134 /// The override read rides the ambient org scope (ADR-0029): locations are shared
135 /// masters under the composition's root-shared fence, so a scoped read resolves the
136 /// override for tenant-specific and shared locations alike.
137 pub(super) async fn inventory_leg_account(
138 &self,
139 location_id: Uuid,
140 header_account_id: Uuid,
141 ) -> Result<Uuid, InventoryError> {
142 Ok(self
143 .valuation_overlay
144 .fetch_location_valuation_override(&self.rpool(), location_id)
145 .await?
146 .unwrap_or(header_account_id))
147 }
148
149 /// The explicit account-move gate (the `_should_create_account_move` port): a posting
150 /// surface builds an envelope only when the thing being posted carries something — value
151 /// OR quantity — and every account its shape needs is configured. A voucher/move that
152 /// carries neither (all-zero lines) builds no envelope and its posting_state stays
153 /// `not_applicable`. Consulted by the door envelope builders (with the voucher totals)
154 /// and by the engine's [`super::inventory_move_engine`] envelope builder (with the
155 /// move's leg values); the accounts-configured half is also what the directive's
156 /// optional accounts express inside the engine's shape branches.
157 pub(super) fn should_create_account_move(
158 value: Decimal,
159 qty: Decimal,
160 accounts_configured: bool,
161 ) -> bool {
162 accounts_configured && !(value.is_zero() && qty.is_zero())
163 }
164}