backbone_pos/application/service/pos_drawer.rs
1//! The cash drawer: non-sale movements, the X/Z read, and the close (hand-authored, user-owned).
2//!
3//! An `impl PosWriteService` chunk over the vocabulary in [`super::pos_write_service`]. `compute_drawer`
4//! is the single expected-drawer calculation shared by `x_report` (mid-shift, read-only) and
5//! `close_session` (the Z-report that counts and closes), so the two can never drift.
6//!
7//! Per the module's 4-layer rule this file holds no SQL — the statements live on
8//! `PosOpeningEntryRepository` / `PosInvoiceRepository` / `PosPaymentRepository` /
9//! `PosCashMovementRepository` / `PosClosingEntryRepository`. The closing-entry insert and the
10//! session's flip to `closed` take THIS service's transaction, so a drawer is never counted without
11//! the session closing.
12//!
13//! Tenancy is composition-installed (ADR-0029): every read rides the ambient org scope (under HTTP
14//! the request-dedicated connection carries it), every write opens its own transaction and re-binds
15//! that scope onto it. The variance request's `company_id` — the GL-post envelope's legacy twin — is
16//! sourced from the ambient scope's legacy company echo.
17
18use rust_decimal::Decimal;
19use std::collections::BTreeMap;
20use uuid::Uuid;
21
22use crate::infrastructure::persistence::{NewCashMovementRow, NewClosingEntryRow};
23
24use super::pos_events::{PosEvent, PosSessionClosed};
25use super::pos_write_service::{
26 legacy_company_echo, money, relay_ambient_scope, CloseOutcome, MethodExpected, MethodRecon,
27 NewCashMovement, NewClose, PosError, PosWriteService, XReport,
28};
29
30impl PosWriteService {
31 // ---- cash movements (non-sale drawer in/out) ---------------------------
32
33 /// Record a non-sale cash drawer movement (`pay_in` / `pay_out` / `drop` / `no_sale`) against an
34 /// OPEN session in the caller's scope. `close_session` folds pay-ins into and pay-outs/drops out of
35 /// the expected cash drawer, so a mid-shift movement no longer surfaces as an unexplained variance.
36 pub async fn record_cash_movement(&self, m: NewCashMovement) -> Result<Uuid, PosError> {
37 // Validate the kind + amount rule: no_sale carries no cash; the others are strictly positive.
38 let amount = money(m.amount);
39 match m.movement_type.as_str() {
40 "no_sale" if amount != Decimal::ZERO => return Err(PosError::InvalidCashMovement("no_sale must have amount 0")),
41 "pay_in" | "pay_out" | "drop" if amount <= Decimal::ZERO => return Err(PosError::InvalidCashMovement("amount must be positive")),
42 "no_sale" | "pay_in" | "pay_out" | "drop" => {}
43 _ => return Err(PosError::InvalidCashMovement("unknown movement_type")),
44 }
45 // Tenancy (ADR-0029): own transaction + ambient scope relay — the session-status read and the
46 // movement insert ride the same connection, so the open-guard sees exactly the fence the
47 // insert writes under. Another unit's session reads as plain absence (fail-closed).
48 let mut tx = self.rpool().begin().await?;
49 relay_ambient_scope(&mut tx).await?;
50 let st = self.openings.fetch_status_on(&mut *tx, m.opening_entry_id).await?;
51 if st.as_deref() != Some("open") { return Err(PosError::SessionNotOpen); }
52
53 let id = Uuid::new_v4();
54 self.movements.insert_movement(&mut tx, &NewCashMovementRow {
55 id,
56 pos_profile_id: m.pos_profile_id,
57 opening_entry_id: m.opening_entry_id,
58 cashier_party_id: m.cashier_party_id,
59 movement_type: &m.movement_type,
60 amount,
61 reason: m.reason.as_deref(),
62 moved_at: m.moved_at,
63 }).await?;
64 tx.commit().await?;
65 Ok(id)
66 }
67
68 // ---- drawer read (shared by close + X-report) --------------------------
69
70 /// Compute the expected drawer for a session: per-method `expected = opening_float + Σ recognised
71 /// tenders` (cash also `− Σ change_due + Σ cash-movement net`), plus the recognised grand total and
72 /// paid-ticket count. Read-only, ambient-scope — shared by `close_session` (Z-report) and
73 /// `x_report` (mid-shift read) so the two can never drift.
74 pub(super) async fn compute_drawer(&self, opening_entry_id: Uuid) -> Result<(BTreeMap<String, Decimal>, Decimal, i64), PosError> {
75 // Read-only: every read below rides the ambient org scope on the request-dedicated
76 // connection (ADR-0029) — a session another unit owns reads as plain absence.
77 let opening_json = self.openings
78 .fetch_opening_balances(&self.rpool(), opening_entry_id).await?
79 .ok_or(PosError::SessionNotFound(opening_entry_id))?;
80 let mut expected: BTreeMap<String, Decimal> = BTreeMap::new();
81 // A session with a NULL `opening_balances` (opened with no float) leaves `expected` empty —
82 // only a MISSING session is an error, and the repo's outer `Option` carried that above.
83 if let Some(sqlx::types::Json(serde_json::Value::Array(arr))) = opening_json {
84 for e in arr {
85 if let (Some(m), Some(a)) = (e.get("method").and_then(|v| v.as_str()), e.get("amount").and_then(|v| v.as_str())) {
86 *expected.entry(m.to_string()).or_insert(Decimal::ZERO) += Decimal::from_str_exact(a).unwrap_or(Decimal::ZERO);
87 }
88 }
89 }
90 // Σ tenders per method for recognised (paid) tickets in the session.
91 let tender_rows = self.payments.sum_by_method_for_session(&self.rpool(), opening_entry_id).await?;
92 for r in &tender_rows {
93 *expected.entry(r.method.clone()).or_insert(Decimal::ZERO) += r.total;
94 }
95 // Cash change reduces the drawer.
96 let cash_change = self.invoices.sum_change_due_for_session(&self.rpool(), opening_entry_id).await?;
97 if cash_change > Decimal::ZERO {
98 *expected.entry("cash".to_string()).or_insert(Decimal::ZERO) -= cash_change;
99 }
100 // Non-sale cash movements: pay-ins add to the drawer, pay-outs and drops remove from it
101 // (no_sale has no cash effect). Folding the net in here means these no longer read as variance.
102 let cash_movement_net = self.movements.net_for_session(&self.rpool(), opening_entry_id).await?;
103 if cash_movement_net != Decimal::ZERO {
104 *expected.entry("cash".to_string()).or_insert(Decimal::ZERO) += cash_movement_net;
105 }
106 let (grand_total, invoice_count) = self.invoices.session_totals(&self.rpool(), opening_entry_id).await?;
107 Ok((expected, grand_total, invoice_count))
108 }
109
110 /// Mid-shift drawer read (X-report): the SAME expected drawer + running totals as the Z-report, but
111 /// without counting, closing the session, or writing anything. Read-only + idempotent — a cashier
112 /// can pull it any number of times during the shift. Requires an OPEN session in the caller's scope.
113 pub async fn x_report(&self, opening_entry_id: Uuid) -> Result<XReport, PosError> {
114 // Read-only, ambient scope (ADR-0029).
115 let st = self.openings.fetch_status(&self.rpool(), opening_entry_id).await?;
116 match st.as_deref() {
117 None => return Err(PosError::SessionNotFound(opening_entry_id)),
118 Some("open") => {}
119 Some(_) => return Err(PosError::SessionNotOpen),
120 }
121 let (expected, grand_total, invoice_count) = self.compute_drawer(opening_entry_id).await?;
122 let by_method = expected.into_iter().map(|(method, exp)| MethodExpected { method, expected: money(exp) }).collect();
123 Ok(XReport { opening_entry_id, by_method, grand_total: money(grand_total), invoice_count })
124 }
125
126 // ---- close (drawer reconciliation) -------------------------------------
127
128 /// Close a session — the Z-report. A PRIVILEGED mutation: the manager PIN on
129 /// [`NewClose::manager`] is verified server-side before anything is read or written, because a
130 /// close is the one POS verb that books a GL correction on its own account.
131 ///
132 /// Order of operations (every guard runs before any write — a failed close leaves NOTHING):
133 ///
134 /// 1. **Guards.** The session must exist and be open; it must hold no DRAFT sale tickets
135 /// ([`PosError::SessionHasDraftOrders`] — post or void them first); and no half-recognised
136 /// tickets (billing linked but the draft→paid flip never landed —
137 /// [`PosError::SessionHasUnpostedInvoices`]; retry recognition first). The manager PIN is
138 /// verified. A second open session on the register is impossible by the one-open-session
139 /// constraint, and opening a new one while this close is in flight collides there and surfaces
140 /// as [`PosError::SessionAlreadyOpen`].
141 /// 2. **Count.** For each tender method `expected = opening_float + Σ recognised tenders` (cash
142 /// also `− Σ change_due + Σ cash-movement net`) — the statement legs (what the journals say)
143 /// against the counted legs (what the drawer holds). `difference = counted − expected`.
144 /// 3. **Variance.** A non-zero net difference books through the `PosCashVariancePort` to the
145 /// register's cash account against its difference/write-off account — the ONE new GL surface a
146 /// close produces (per-ticket posting stays THE posting path). Booking happens BEFORE the
147 /// closing-entry transaction: if the seam refuses, the close rolls back entirely (nothing has
148 /// been written); the seam's idempotency key is the session, so a crash between booking and
149 /// commit re-books nothing on retry.
150 /// 4. **Persist.** The closing entry (per-method breakdown + grand total + difference) and the
151 /// session's flip to `closed` commit as ONE unit, then `PosSessionClosed` is emitted.
152 pub async fn close_session(
153 &self,
154 c: NewClose,
155 variance: &dyn super::pos_ports::PosCashVariancePort,
156 ) -> Result<CloseOutcome, PosError> {
157 // Tenancy (ADR-0029): the guard reads ride the ambient org scope; the write transactions
158 // below open their own connection and re-bind that scope onto it.
159 // Guard: the session must exist and be open (this is also what makes close effectively-once —
160 // a retried close of an already-closed session refuses here).
161 let st = self.openings.fetch_status(&self.rpool(), c.opening_entry_id).await?;
162 match st.as_deref() {
163 None => return Err(PosError::SessionNotFound(c.opening_entry_id)),
164 Some("open") => {}
165 Some(_) => return Err(PosError::SessionNotOpen),
166 }
167 // Guard: no DRAFT sale tickets may remain — a draft has no GL and would orphan the drawer.
168 let drafts = self.invoices.count_draft_orders_for_session(&self.rpool(), c.opening_entry_id).await?;
169 if drafts > 0 {
170 return Err(PosError::SessionHasDraftOrders(drafts));
171 }
172 // Guard: no half-recognised tickets — billing raised but the settle never landed. Closing
173 // over them would strand a posted invoice against an unclosed drawer.
174 let unposted = self.invoices.count_linked_unposted_for_session(&self.rpool(), c.opening_entry_id).await?;
175 if unposted > 0 {
176 return Err(PosError::SessionHasUnpostedInvoices(unposted));
177 }
178 // Guard: privileged — verify the manager's PIN (server-side, against the stored hash, with the
179 // caller's source address feeding the per-address throttle ring).
180 self.verify_manager_internal(&c.manager, c.source_ip.as_deref()).await?;
181
182 let (expected, grand_total, invoice_count) = self.compute_drawer(c.opening_entry_id).await?;
183
184 let counted: BTreeMap<String, Decimal> = c.counted.iter().map(|(m, a)| (m.clone(), money(*a))).collect();
185 let mut methods: std::collections::BTreeSet<String> = expected.keys().cloned().collect();
186 methods.extend(counted.keys().cloned());
187 let mut by_method = Vec::new();
188 let mut difference_total = Decimal::ZERO;
189 for m in methods {
190 let exp = money(*expected.get(&m).unwrap_or(&Decimal::ZERO));
191 let cnt = *counted.get(&m).unwrap_or(&Decimal::ZERO);
192 let diff = cnt - exp;
193 difference_total += diff;
194 by_method.push(MethodRecon { method: m, expected: exp, counted: cnt, difference: diff });
195 }
196 let difference_total = money(difference_total);
197 let totals_json = serde_json::Value::Array(by_method.iter().map(|r| serde_json::json!({
198 "method": r.method, "expected": r.expected.to_string(), "counted": r.counted.to_string(), "difference": r.difference.to_string(),
199 })).collect());
200
201 // Variance booking (the one new GL surface at close): only a NON-ZERO difference books — a
202 // balanced drawer raises no journal. Both the register's cash account and its write-off
203 // account must be configured when the drawer did not balance.
204 let mut booked: Option<super::pos_write_service::VarianceBooking> = None;
205 if difference_total != Decimal::ZERO {
206 let mut tx = self.rpool().begin().await?;
207 relay_ambient_scope(&mut tx).await?;
208 let (cash_account, write_off_account, currency) = self
209 .openings
210 .fetch_variance_accounts_on(&mut tx, c.opening_entry_id)
211 .await?;
212 let closing_id = Uuid::new_v4();
213 let cash_account = cash_account
214 .ok_or(PosError::MissingAccount("cash_account_id (register settles a cash drawer)"))?;
215 let write_off_account = write_off_account
216 .ok_or(PosError::MissingAccount("write_off_account_id (register books drawer variance)"))?;
217 let direction = if difference_total > Decimal::ZERO {
218 super::pos_ports::CashVarianceDirection::Over
219 } else {
220 super::pos_ports::CashVarianceDirection::Short
221 };
222 let ack = variance
223 .book_cash_variance(&super::pos_ports::CashVarianceRequest {
224 company_id: legacy_company_echo(),
225 opening_entry_id: c.opening_entry_id,
226 closing_entry_id: closing_id,
227 posting_date: c.closed_at.date(),
228 currency,
229 cash_account_id: cash_account,
230 difference_account_id: write_off_account,
231 amount: difference_total.abs(),
232 direction,
233 })
234 .await
235 .map_err(|e| PosError::VarianceRejected { code: e.code, message: e.message })?;
236 booked = Some(super::pos_write_service::VarianceBooking {
237 journal_id: ack.journal_id,
238 amount: difference_total.abs(),
239 direction,
240 });
241 tx.commit().await?;
242 // The closing entry below reuses this id so the booked journal traces to it.
243 return self.finish_close(c, closing_id, totals_json, grand_total, invoice_count, difference_total, by_method, booked).await;
244 }
245
246 let id = Uuid::new_v4();
247 self.finish_close(c, id, totals_json, grand_total, invoice_count, difference_total, by_method, booked).await
248 }
249
250 /// The persist + emit tail of a close: closing entry + session flip in ONE transaction, then the
251 /// `PosSessionClosed` event. Split out only so the variance-booked path can pass its pre-chosen
252 /// closing id.
253 async fn finish_close(
254 &self,
255 c: NewClose,
256 closing_id: Uuid,
257 totals_json: serde_json::Value,
258 grand_total: Decimal,
259 invoice_count: i64,
260 difference_total: Decimal,
261 by_method: Vec<MethodRecon>,
262 booked: Option<super::pos_write_service::VarianceBooking>,
263 ) -> Result<CloseOutcome, PosError> {
264 let mut tx = self.rpool().begin().await?;
265 relay_ambient_scope(&mut tx).await?;
266 let profile_id = self
267 .openings
268 .fetch_profile_id_on(&mut tx, c.opening_entry_id)
269 .await?
270 .ok_or(PosError::SessionNotFound(c.opening_entry_id))?;
271 self.closings.insert_closing_entry(&mut tx, &NewClosingEntryRow {
272 id: closing_id,
273 pos_profile_id: profile_id,
274 opening_entry_id: c.opening_entry_id,
275 closed_at: c.closed_at,
276 cashier_party_id: c.cashier_party_id,
277 totals_by_method: totals_json,
278 grand_total: money(grand_total),
279 invoice_count: invoice_count as i32,
280 difference_total: money(difference_total),
281 }).await?;
282 self.openings.mark_closed(&mut tx, c.opening_entry_id).await?;
283 tx.commit().await?;
284
285 // The `company_id` field is the documented legacy twin (ADR-0029) — the acting unit id from
286 // the ambient scope's echo.
287 self.sink.publish(PosEvent::PosSessionClosed(PosSessionClosed {
288 closing_entry_id: closing_id, opening_entry_id: c.opening_entry_id, company_id: legacy_company_echo(),
289 difference_total: money(difference_total),
290 }));
291 Ok(CloseOutcome { closing_id, difference_total: money(difference_total), by_method, variance: booked })
292 }
293}