backbone-inventory 0.17.2

Inventory: stock ledger of record (SLE) + moving-average valuation; emits COGS/asset AccountingPost (Delivery/Receipt -> GL)
Documentation
//! GL voucher repository — the hand-written SQL that reconciles a voucher's `posting_state` against
//! the accounting ack, across ALL THREE posting voucher tables.
//!
//! Hand-authored and **user-owned**: this exact path is declared under `user_owned` in
//! `metaphor.codegen.yaml`. It is not schema-derived and has no entity of its own.
//!
//! **Why this file exists (the one place the per-entity repository shape did not fit).** The
//! GL-posting contract is uniform across vouchers: the physical movement (SLE + Bin) commits first,
//! then the post is emitted and the voucher's `posting_state` goes pending→posted|failed. That same
//! reconcile runs against `purchase_receipts`, `delivery_notes` or `stock_reconciliations` depending
//! on which voucher is being posted — so it is nobody's entity SQL, and duplicating it three ways
//! would be three places to get the idempotency guard wrong. The table name is chosen ONLY from the
//! closed [`GlVoucher`] enum — never interpolated from a caller string — so the `format!`s below
//! cannot carry untrusted input.

use sqlx::PgPool;
use uuid::Uuid;

use backbone_orm::org_scope;

/// Which voucher table a GL post reconciles against. A closed enum, so the interpolated table name
/// can only ever be one of a few compile-time literals.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlVoucher {
    PurchaseReceipt,
    DeliveryNote,
    StockReconciliation,
    /// The landed-cost document's own post (Dr inventory valuation / Cr cost lines). Shares the
    /// same posting_state reconcile columns as the three voucher doors. Never used with
    /// [`Self::mark_reversal_posted`] — a validated landed cost cannot cancel (the reversal
    /// pattern is a negative-amount landed cost), and its table carries no reversal columns.
    LandedCost,
}

impl GlVoucher {
    fn table(self) -> &'static str {
        match self {
            GlVoucher::PurchaseReceipt => "purchase_receipts",
            GlVoucher::DeliveryNote => "delivery_notes",
            GlVoucher::StockReconciliation => "stock_reconciliations",
            GlVoucher::LandedCost => "landed_costs",
        }
    }
}

/// A voucher's GL settlement state, as the repost path reads it. Shared by every voucher header
/// projection that carries it — the shape is identical across the three tables.
pub struct GlSettlementState {
    /// `posting_state::text` — "pending" | "posted" | "failed" | "not_applicable".
    pub posting_state: String,
    pub journal_id: Option<Uuid>,
    pub accounting_post_id: Option<Uuid>,
}

/// The GL reconcile's SQL. Stateless: it holds no pool, because both methods run outside the
/// movement's transaction — the physical movement is already committed by the time a post is
/// emitted, and the reconcile is deliberately a separate write (the GL leg is eventually
/// consistent).
pub struct GlVoucherRepository;

impl Default for GlVoucherRepository {
    fn default() -> Self { Self::new() }
}

impl GlVoucherRepository {
    pub fn new() -> Self { Self }

    /// Reconcile a voucher from the accounting ack — the pending→posted transition.
    ///
    /// Guarded on `posting_state <> 'posted'` so a repost of an already-posted voucher cannot
    /// overwrite the original journal's ids: accounting dedupes on
    /// `(source_type, source_id, posting_type)` and hands back the ORIGINAL journal, and this guard
    /// is what keeps the recorded ids stable through that.
    ///
    /// Runs `org_scope::execute_scoped` on the pool — the write rides the ambient org scope's
    /// request-dedicated connection so it passes the composing decorator's fence (ADR-0029).
    /// Undecorated (module tests) the UPDATE runs plain.
    pub async fn mark_posted(
        &self,
        pool: &PgPool,
        voucher: GlVoucher,
        voucher_id: Uuid,
        journal_id: Uuid,
        post_id: Uuid,
    ) -> Result<(), sqlx::Error> {
        let sql = format!(
            "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",
            voucher.table(),
        );
        org_scope::execute_scoped(
            pool,
            sqlx::query(&sql).bind(voucher_id).bind(journal_id).bind(post_id),
        )
        .await?;
        Ok(())
    }

    /// Record a GL rejection. The physical movement is NOT rolled back — it really happened; the
    /// voucher parks in `failed` and is re-drivable via the service's `repost_*` entrypoints.
    ///
    /// Same scoped-execute discipline as [`Self::mark_posted`].
    pub async fn mark_failed(
        &self,
        pool: &PgPool,
        voucher: GlVoucher,
        voucher_id: Uuid,
    ) -> Result<(), sqlx::Error> {
        let sql = format!(
            "UPDATE inventory.{} SET posting_state='failed'::gl_posting_state WHERE id=$1",
            voucher.table(),
        );
        org_scope::execute_scoped(pool, sqlx::query(&sql).bind(voucher_id)).await?;
        Ok(())
    }

    /// Record the GL REVERSAL post for a cancelled voucher (council 2026-07-29, #3).
    ///
    /// Distinct from [`Self::mark_posted`]: the original post is already `posted` with its own
    /// `journal_id`/`accounting_post_id` (which stay intact — the original really happened). A
    /// cancellation appends compensating SLEs and emits a `posting_type='reversal'` post; that
    /// reversal's ids land in `reversal_journal_id`/`reversal_accounting_post_id`. There is no
    /// `posting_state` guard here — the reversal is recorded regardless of the original's state.
    pub async fn mark_reversal_posted(
        &self,
        pool: &PgPool,
        voucher: GlVoucher,
        voucher_id: Uuid,
        journal_id: Uuid,
        post_id: Uuid,
    ) -> Result<(), sqlx::Error> {
        let sql = format!(
            "UPDATE inventory.{} SET reversal_journal_id=$2, reversal_accounting_post_id=$3 WHERE id=$1",
            voucher.table(),
        );
        org_scope::execute_scoped(
            pool,
            sqlx::query(&sql).bind(voucher_id).bind(journal_id).bind(post_id),
        )
        .await?;
        Ok(())
    }

    /// Retire a voucher's GL leg to `not_applicable` — the document genuinely posts no GL under
    /// the current configuration. Two users: a landed-cost validation whose entire allocation
    /// fell on already-consumed stock (Σ remaining-share δ = 0: nothing to revalue, nothing to
    /// post), and a `valuation_policy='periodic'` posture whose real-time stock posts are
    /// suppressed (the periodic closing flow — a later increment — owns those legs). Idempotent:
    /// re-marking an already-retired voucher is a no-op. Same scoped-execute discipline as
    /// [`Self::mark_posted`].
    pub async fn mark_not_applicable(
        &self,
        pool: &PgPool,
        voucher: GlVoucher,
        voucher_id: Uuid,
    ) -> Result<(), sqlx::Error> {
        let sql = format!(
            "UPDATE inventory.{} SET posting_state='not_applicable'::gl_posting_state WHERE id=$1",
            voucher.table(),
        );
        org_scope::execute_scoped(pool, sqlx::query(&sql).bind(voucher_id)).await?;
        Ok(())
    }
}