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);