Skip to main content

backbone_pos/infrastructure/persistence/
pos_cash_movement_repository.rs

1//! Repository for PosCashMovement entities
2//!
3//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
4//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
5//! below hold the hand-written non-sale drawer-movement SQL (4-layer rule: services orchestrate, repos hold SQL).
6//!
7//! Thin newtype over `backbone_orm::GenericCrudRepository<PosCashMovement, backbone_orm::SoftDelete>`.
8//! All standard CRUD methods are available via `Deref`.
9
10use anyhow::Result;
11use rust_decimal::Decimal;
12use sqlx::{PgPool, Row};
13use uuid::Uuid;
14
15// The scalar read twin lives only in the legacy `company_scope` module; what this repository needs
16// from it is the connection discipline — request-dedicated connection when the composing service
17// bound one, plain pool otherwise. Its legacy task-local branch never fires: this module sets no
18// legacy scope of its own (ADR-0029).
19use backbone_orm::company_scope::fetch_one_scalar_scoped;
20
21use crate::domain::entity::PosCashMovement;
22
23/// Table name for PosCashMovement entities
24pub const TABLE_NAME: &str = "pos.pos_cash_movements";
25
26/// Repository for PosCashMovement entities.
27///
28/// All standard CRUD, soft-delete, pagination, and bulk methods are
29/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
30pub struct PosCashMovementRepository(
31    backbone_orm::GenericCrudRepository<PosCashMovement, backbone_orm::SoftDelete>,
32);
33
34impl std::ops::Deref for PosCashMovementRepository {
35    type Target = backbone_orm::GenericCrudRepository<PosCashMovement, backbone_orm::SoftDelete>;
36    fn deref(&self) -> &Self::Target { &self.0 }
37}
38
39impl PosCashMovementRepository {
40    /// Create a new repository instance.
41    pub fn new(pool: PgPool) -> Self {
42        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
43    }
44}
45
46/// The exact row a non-sale drawer movement writes. Mirrors the raw column shape rather than the
47/// `PosCashMovement` entity: `movement_type` binds as `&str` with a DB-side cast
48/// (`$6::pos_cash_movement_type`), so an unknown kind fails as a DB error, not a deserialize panic.
49pub struct NewCashMovementRow<'a> {
50    pub id: Uuid,
51    pub pos_profile_id: Uuid,
52    pub opening_entry_id: Uuid,
53    pub cashier_party_id: Uuid,
54    pub movement_type: &'a str,
55    pub amount: Decimal,
56    pub reason: Option<&'a str>,
57    pub moved_at: chrono::NaiveDateTime,
58}
59
60/// Hand-written non-sale drawer-movement SQL. Lives here (not in the write service) per the module's
61/// 4-layer rule: services orchestrate and own the unit of work, repositories hold the SQL.
62impl PosCashMovementRepository {
63    /// Record a non-sale drawer movement (`pay_in` / `pay_out` / `drop` / `no_sale`).
64    ///
65    /// Takes the CALLER'S connection so the open-session guard and the movement commit as one unit;
66    /// the caller has already relayed the ambient org scope onto it (ADR-0029), which is what
67    /// satisfies the fence's WITH CHECK under a composed host.
68    pub async fn insert_movement(
69        &self,
70        conn: &mut sqlx::PgConnection,
71        m: &NewCashMovementRow<'_>,
72    ) -> Result<(), sqlx::Error> {
73        sqlx::query(
74            r#"INSERT INTO pos.pos_cash_movements
75                (id, pos_profile_id, opening_entry_id, cashier_party_id, movement_type, amount, reason, moved_at)
76               VALUES ($1,$2,$3,$4,$5::pos_cash_movement_type,$6,$7,$8)"#,
77        )
78        .bind(m.id).bind(m.pos_profile_id).bind(m.opening_entry_id).bind(m.cashier_party_id)
79        .bind(m.movement_type).bind(m.amount).bind(m.reason).bind(m.moved_at)
80        .execute(conn)
81        .await?;
82        Ok(())
83    }
84
85    /// Net cash effect of a session's non-sale movements: pay-ins add to the drawer, pay-outs and drops
86    /// remove from it, `no_sale` has no cash effect. Folding this into the expected drawer is what stops
87    /// a mid-shift movement reading as an unexplained variance.
88    ///
89    /// ID-only: rides the ambient org scope (ADR-0029) — the fence bounds the sum to the caller's
90    /// entitled units.
91    pub async fn net_for_session(
92        &self,
93        pool: &PgPool,
94        opening_entry_id: Uuid,
95    ) -> Result<Decimal, sqlx::Error> {
96        fetch_one_scalar_scoped(
97            pool,
98            sqlx::query_scalar(
99                r#"SELECT COALESCE(SUM(CASE movement_type
100                        WHEN 'pay_in' THEN amount
101                        WHEN 'pay_out' THEN -amount
102                        WHEN 'drop' THEN -amount
103                        ELSE 0 END), 0)
104                   FROM pos.pos_cash_movements
105                   WHERE opening_entry_id=$1 AND (metadata->>'deleted_at') IS NULL"#,
106            )
107            .bind(opening_entry_id),
108        )
109        .await
110    }
111}
112
113backbone_core::impl_crud_repository!(PosCashMovementRepository, PosCashMovement, soft_delete);