Skip to main content

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.db_pool.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.db_pool, 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.db_pool, 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.db_pool, 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.db_pool, 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.db_pool, 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.db_pool, 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.db_pool, 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.db_pool, 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.db_pool, 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.db_pool.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.db_pool.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}