backbone_pos/infrastructure/persistence/pos_opening_entry_repository.rs
1//! Repository for PosOpeningEntry 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 cashier-session SQL (4-layer rule: services orchestrate, repos hold SQL).
6//!
7//! Thin newtype over `backbone_orm::GenericCrudRepository<PosOpeningEntry, backbone_orm::SoftDelete>`.
8//! All standard CRUD methods are available via `Deref`.
9//!
10//! Tenancy is composition-installed (ADR-0029): pool reads ride the ambient org scope's
11//! request-dedicated connection (a session another unit owns reads as plain absence); the
12//! connection-taking methods run on the CALLER'S open, scope-relayed transaction so a session's
13//! guards and the writes they gate see the same world.
14
15use anyhow::Result;
16use sqlx::PgPool;
17use uuid::Uuid;
18
19use backbone_orm::org_scope;
20// The scalar read twin lives only in the legacy `company_scope` module; what this repository needs
21// from it is the connection discipline — request-dedicated connection when the composing service
22// bound one, plain pool otherwise. Its legacy task-local branch never fires: this module sets no
23// legacy scope of its own (ADR-0029).
24use backbone_orm::company_scope::fetch_optional_scalar_scoped;
25
26use crate::domain::entity::PosOpeningEntry;
27
28/// Table name for PosOpeningEntry entities
29pub const TABLE_NAME: &str = "pos.pos_opening_entries";
30
31/// Repository for PosOpeningEntry entities.
32///
33/// All standard CRUD, soft-delete, pagination, and bulk methods are
34/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
35pub struct PosOpeningEntryRepository(
36 backbone_orm::GenericCrudRepository<PosOpeningEntry, backbone_orm::SoftDelete>,
37);
38
39impl std::ops::Deref for PosOpeningEntryRepository {
40 type Target = backbone_orm::GenericCrudRepository<PosOpeningEntry, backbone_orm::SoftDelete>;
41 fn deref(&self) -> &Self::Target { &self.0 }
42}
43
44impl PosOpeningEntryRepository {
45 /// Create a new repository instance.
46 pub fn new(pool: PgPool) -> Self {
47 Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
48 }
49}
50
51/// The exact row an `open_session` writes.
52///
53/// Mirrors the raw column shape rather than the `PosOpeningEntry` entity: `opening_balances` is the
54/// already-serialised per-method float array, and `status` is fixed to `'open'` by the SQL (cast
55/// DB-side as `pos_session_status`). No tenancy column — the composing decorator's fill trigger
56/// stamps the acting unit (ADR-0029).
57pub struct NewOpeningEntryRow {
58 pub id: Uuid,
59 pub pos_profile_id: Uuid,
60 pub branch_id: Option<Uuid>,
61 pub cashier_party_id: Uuid,
62 pub opened_at: chrono::NaiveDateTime,
63 pub opening_balances: serde_json::Value,
64}
65
66/// An open session claimed by the old-session alert scheduler: everything the alert consumer (the
67/// host wiring a mail activity onto the cashier) needs to act, plus the anchors the emitted
68/// `PosStaleSessionAlerted` event carries.
69pub struct StaleSessionRow {
70 pub opening_entry_id: Uuid,
71 pub pos_profile_id: Uuid,
72 pub cashier_party_id: Uuid,
73 pub opened_at: chrono::NaiveDateTime,
74}
75
76/// Hand-written cashier-session SQL. Lives here (not in the write service) per the module's 4-layer
77/// rule: services orchestrate and own the unit of work, repositories hold the SQL.
78impl PosOpeningEntryRepository {
79 /// Open a cashier session.
80 ///
81 /// Takes the CALLER'S connection so the register/one-open-session guards and the insert commit
82 /// as one unit; the caller has already relayed the ambient org scope onto it, which is what
83 /// satisfies the fence's WITH CHECK under a composed host (and leaves the insert plain on a
84 /// standalone deployment).
85 pub async fn insert_opening_entry(
86 &self,
87 conn: &mut sqlx::PgConnection,
88 s: &NewOpeningEntryRow,
89 ) -> Result<(), sqlx::Error> {
90 sqlx::query(
91 r#"INSERT INTO pos.pos_opening_entries
92 (id, pos_profile_id, branch_id, cashier_party_id, opened_at, opening_balances, status)
93 VALUES ($1,$2,$3,$4,$5,$6,'open'::pos_session_status)"#,
94 )
95 .bind(s.id).bind(s.pos_profile_id).bind(s.branch_id).bind(s.cashier_party_id)
96 .bind(s.opened_at).bind(sqlx::types::Json(&s.opening_balances))
97 .execute(conn)
98 .await?;
99 Ok(())
100 }
101
102 /// Read a session's status (pool variant, for guards outside a unit of work). ID-only: rides the
103 /// ambient org scope — `Ok(None)` covers "no such session" and "another unit's session" alike,
104 /// and deliberately cannot tell them apart.
105 pub async fn fetch_status(
106 &self,
107 pool: &PgPool,
108 opening_entry_id: Uuid,
109 ) -> Result<Option<String>, sqlx::Error> {
110 fetch_optional_scalar_scoped(
111 pool,
112 sqlx::query_scalar(
113 "SELECT status::text FROM pos.pos_opening_entries WHERE id=$1 AND (metadata->>'deleted_at') IS NULL",
114 )
115 .bind(opening_entry_id),
116 )
117 .await
118 }
119
120 /// Read a session's status on the CALLER'S scope-relayed transaction — the guard inside a unit
121 /// of work (ring/movement/sync), so the read sees exactly the fence the paired write writes under.
122 pub async fn fetch_status_on(
123 &self,
124 conn: &mut sqlx::PgConnection,
125 opening_entry_id: Uuid,
126 ) -> Result<Option<String>, sqlx::Error> {
127 sqlx::query_scalar(
128 "SELECT status::text FROM pos.pos_opening_entries WHERE id=$1 AND (metadata->>'deleted_at') IS NULL",
129 )
130 .bind(opening_entry_id)
131 .fetch_optional(conn)
132 .await
133 }
134
135 /// Read a session's opening float array (the per-method drawer starting balance). Same ID-only
136 /// scope contract as [`Self::fetch_status`].
137 ///
138 /// The `Option` nests deliberately, and the caller distinguishes the two levels: the OUTER `None`
139 /// means no such session (the caller raises `SessionNotFound`), while an inner `None` means the
140 /// session exists with a NULL `opening_balances` (a drawer that opened with no float — expected
141 /// starts empty, which is not an error).
142 #[allow(clippy::type_complexity)]
143 pub async fn fetch_opening_balances(
144 &self,
145 pool: &PgPool,
146 opening_entry_id: Uuid,
147 ) -> Result<Option<Option<sqlx::types::Json<serde_json::Value>>>, sqlx::Error> {
148 fetch_optional_scalar_scoped(
149 pool,
150 sqlx::query_scalar(
151 "SELECT opening_balances FROM pos.pos_opening_entries WHERE id=$1 AND (metadata->>'deleted_at') IS NULL",
152 )
153 .bind(opening_entry_id),
154 )
155 .await
156 }
157
158 /// Whether the register already holds an OPEN session — the service-side arm of the
159 /// one-open-session rule. Runs on the CALLER'S scope-relayed transaction so the check and the
160 /// open insert it guards see the same world (the composition-installed per-(unit, register)
161 /// partial unique is the race backstop).
162 pub async fn has_open_session(
163 &self,
164 conn: &mut sqlx::PgConnection,
165 pos_profile_id: Uuid,
166 ) -> Result<bool, sqlx::Error> {
167 let found: Option<i32> = sqlx::query_scalar(
168 r#"SELECT 1 FROM pos.pos_opening_entries
169 WHERE pos_profile_id=$1 AND status='open'::pos_session_status
170 AND (metadata->>'deleted_at') IS NULL"#,
171 )
172 .bind(pos_profile_id)
173 .fetch_optional(conn)
174 .await?;
175 Ok(found.is_some())
176 }
177
178 /// Read the session's register (pool variant, for lookups outside a unit of work). ID-only:
179 /// rides the ambient org scope; `Ok(None)` = no such session in the caller's scope.
180 pub async fn fetch_profile_id(
181 &self,
182 pool: &PgPool,
183 opening_entry_id: Uuid,
184 ) -> Result<Option<Uuid>, sqlx::Error> {
185 fetch_optional_scalar_scoped(
186 pool,
187 sqlx::query_scalar(
188 "SELECT pos_profile_id FROM pos.pos_opening_entries WHERE id=$1 AND (metadata->>'deleted_at') IS NULL",
189 )
190 .bind(opening_entry_id),
191 )
192 .await
193 }
194
195 /// Read the session's register, on the CALLER'S connection so it reads the same snapshot the
196 /// closing-entry insert writes. `Ok(None)` = no such session — the caller maps that refusal.
197 /// The caller has already relayed the ambient org scope onto it — don't re-bind here.
198 pub async fn fetch_profile_id_on(
199 &self,
200 conn: &mut sqlx::PgConnection,
201 opening_entry_id: Uuid,
202 ) -> Result<Option<Uuid>, sqlx::Error> {
203 sqlx::query_scalar("SELECT pos_profile_id FROM pos.pos_opening_entries WHERE id=$1")
204 .bind(opening_entry_id)
205 .fetch_optional(conn)
206 .await
207 }
208
209 /// The variance-booking accounts of a session's register: the cash account (the drawer side) and
210 /// the difference/write-off account (the absorption side), plus the currency the correction
211 /// posts in. Read on the CALLER'S connection so the close sees the same snapshot it writes.
212 /// `Ok(None)` on either account means the register is not configured for variance booking — the
213 /// caller decides whether that refuses the close (non-zero variance) or skips it (balanced).
214 pub async fn fetch_variance_accounts_on(
215 &self,
216 conn: &mut sqlx::PgConnection,
217 opening_entry_id: Uuid,
218 ) -> Result<(Option<Uuid>, Option<Uuid>, String), sqlx::Error> {
219 sqlx::query_as(
220 r#"SELECT p.cash_account_id, p.write_off_account_id, p.currency
221 FROM pos.pos_opening_entries o JOIN pos.pos_profiles p ON p.id = o.pos_profile_id
222 WHERE o.id=$1"#,
223 )
224 .bind(opening_entry_id)
225 .fetch_one(conn)
226 .await
227 }
228
229 /// Flip the session to `closed`. Takes the CALLER'S connection so this and the closing-entry insert
230 /// commit as one unit; the caller has already relayed the ambient scope onto it.
231 pub async fn mark_closed(
232 &self,
233 conn: &mut sqlx::PgConnection,
234 opening_entry_id: Uuid,
235 ) -> Result<(), sqlx::Error> {
236 sqlx::query("UPDATE pos.pos_opening_entries SET status='closed'::pos_session_status WHERE id=$1")
237 .bind(opening_entry_id)
238 .execute(conn)
239 .await?;
240 Ok(())
241 }
242
243 /// Claim the caller's scope's stale open sessions — the intake half of the old-session alert
244 /// scheduler. Runs on the CALLER'S scope-relayed transaction: the fence the caller bound is what
245 /// bounds the claim to the acting unit (ADR-0029 per-unit handler).
246 ///
247 /// `FOR UPDATE SKIP LOCKED` is the pickup lock (ADR-0020 §4): two concurrent runs of the drainer
248 /// never claim the same session. A session is stale when it is still `open`, opened before
249 /// `cutoff`, and has not been alerted yet (the `stale_session_alerted_at` marker in `metadata` is
250 /// the once-only latch — it is what keeps a daily scheduler from re-nagging the same session
251 /// every run). `opened_at` is cast to a wall-clock `timestamp` in the session zone — the exact
252 /// inverse of the cast the open insert applies — so the Rust-side `NaiveDateTime` round-trips
253 /// whatever the server's zone is.
254 pub async fn claim_stale_sessions(
255 &self,
256 conn: &mut sqlx::PgConnection,
257 cutoff: chrono::NaiveDateTime,
258 limit: i64,
259 ) -> Result<Vec<StaleSessionRow>, sqlx::Error> {
260 let rows = sqlx::query_as::<_, (Uuid, Uuid, Uuid, chrono::NaiveDateTime)>(
261 r#"SELECT id, pos_profile_id, cashier_party_id, opened_at::timestamp
262 FROM pos.pos_opening_entries
263 WHERE status='open'::pos_session_status
264 AND opened_at < $1
265 AND (metadata->>'stale_session_alerted_at') IS NULL
266 AND (metadata->>'deleted_at') IS NULL
267 ORDER BY opened_at
268 LIMIT $2
269 FOR UPDATE SKIP LOCKED"#,
270 )
271 .bind(cutoff)
272 .bind(limit)
273 .fetch_all(conn)
274 .await?;
275 Ok(rows
276 .into_iter()
277 .map(|(opening_entry_id, pos_profile_id, cashier_party_id, opened_at)| StaleSessionRow {
278 opening_entry_id,
279 pos_profile_id,
280 cashier_party_id,
281 opened_at,
282 })
283 .collect())
284 }
285
286 /// Stamp the once-only alert marker onto the claimed sessions (same transaction as the claim —
287 /// `commit_policy: single_transaction` per the scheduler declaration). Takes the CALLER'S
288 /// connection.
289 pub async fn mark_stale_alerted(
290 &self,
291 conn: &mut sqlx::PgConnection,
292 opening_entry_ids: &[Uuid],
293 at: chrono::DateTime<chrono::Utc>,
294 ) -> Result<(), sqlx::Error> {
295 sqlx::query(
296 r#"UPDATE pos.pos_opening_entries
297 SET metadata = jsonb_set(COALESCE(metadata, '{}'::jsonb), '{stale_session_alerted_at}', to_jsonb($2::timestamptz))
298 WHERE id = ANY($1)"#,
299 )
300 .bind(opening_entry_ids)
301 .bind(at)
302 .execute(conn)
303 .await?;
304 Ok(())
305 }
306}
307
308backbone_core::impl_crud_repository!(PosOpeningEntryRepository, PosOpeningEntry, soft_delete);