Skip to main content

backbone_pos/application/service/
pos_sale.rs

1//! Ringing a counter ticket (hand-authored, user-owned).
2//!
3//! An `impl PosWriteService` chunk over the vocabulary in [`super::pos_write_service`]: validate the
4//! basket, derive the money through the shared compute core ([`super::pos_compute`] — document-grade
5//! tax via the register's templates + register-config cash rounding), and write the ticket header +
6//! its lines as ONE unit of work. `ring_sale_priced` is the promo cart seam (ADR-002) layered on top.
7//!
8//! Per the module's 4-layer rule this file holds no SQL — the statements live on
9//! `PosInvoiceRepository` / `PosInvoiceItemRepository` / `PosProfileRepository` /
10//! `PosOpeningEntryRepository`, and the header/line repo methods take THIS service's transaction so a
11//! ticket is never half-rung.
12//!
13//! Tenancy is composition-installed (ADR-0029): the verb opens its own transaction, re-binds the
14//! caller's ambient org scope onto it, and runs every validation read on that same connection — so
15//! under a composed host the reads see through the fence exactly what the insert will write beside.
16
17use rust_decimal::Decimal;
18use uuid::Uuid;
19
20use crate::infrastructure::persistence::NewDraftInvoiceRow;
21
22use super::pos_compute::ComputeLineIn;
23use super::pos_ports::PosTaxComputePort;
24use super::pos_write_service::{
25    legacy_company_echo, map_invoice_dup, relay_ambient_scope, PosError, PosWriteService, NewCartSale, NewSale,
26    NewSaleLine,
27};
28
29impl PosWriteService {
30    /// Ring a ticket. The money is SERVER-DERIVED end to end: the caller supplies the basket
31    /// (items/qty/unit price/discount) and the register; the compute core resolves tax through the
32    /// register's configured templates (`tax: &dyn PosTaxComputePort`) and rounds per the register's
33    /// cash-rounding config. Nothing the caller might claim about totals is read.
34    pub async fn ring_sale(&self, sale: NewSale, tax: &dyn PosTaxComputePort) -> Result<Uuid, PosError> {
35        if sale.lines.is_empty() {
36            return Err(PosError::EmptyDocument);
37        }
38        let mut tx = self.rpool().begin().await?;
39        relay_ambient_scope(&mut tx).await?;
40        // Session must be open. Under a composed fence a session another unit owns reads as
41        // plain absence — the refusal cannot distinguish whose it is, and must not.
42        let st = self.openings.fetch_status_on(&mut *tx, sale.opening_entry_id).await?;
43        if st.as_deref() != Some("open") {
44            return Err(PosError::SessionNotOpen);
45        }
46        // ...and the drawer must be this ticket's register's: pairing register A's config with
47        // register B's session would misattribute cash between tills.
48        let session_profile = self
49            .openings
50            .fetch_profile_id_on(&mut *tx, sale.opening_entry_id)
51            .await?
52            .ok_or(PosError::SessionNotFound(sale.opening_entry_id))?;
53        if session_profile != sale.pos_profile_id {
54            return Err(PosError::SessionRegisterMismatch);
55        }
56
57        // Restaurant lane — seating. A named table must exist, and (the one draft per table
58        // rule) no OTHER draft may already sit there: the pre-check gives the caller the
59        // occupying ticket's id in the 409 body; the DB partial unique on (pos_table_id)
60        // WHERE status='draft' is the race backstop (mapped below).
61        if let Some(table) = sale.pos_table_id {
62            if self.tables.fetch_table(&mut *tx, table).await?.is_none() {
63                return Err(PosError::TableNotFound(table));
64            }
65            if let Some(occupant) = self.invoices.find_draft_on_table(&mut *tx, table, None).await? {
66                return Err(PosError::TableOccupied { pos_table_id: table, draft_invoice_id: occupant });
67            }
68        }
69
70        // Restaurant lane — order-level discount. Resolve the RATE from the master
71        // (never the client), then fold it pro-rata into the line discounts BEFORE the compute,
72        // so tax sees post-discount nets.
73        let order_discount = self
74            .resolve_order_discount(&mut tx, sale.pos_profile_id, sale.discount_id)
75            .await?;
76
77        // The one place a ticket's money is derived: templates -> document-grade tax -> register
78        // cash rounding. A register with no configured templates refuses here (fail-closed).
79        let mut compute_lines: Vec<ComputeLineIn> = sale
80            .lines
81            .iter()
82            .map(|l| ComputeLineIn {
83                item_id: l.item_id,
84                revenue_account_id: l.revenue_account_id,
85                description: l.description.clone(),
86                course: l.course,
87                quantity: l.quantity,
88                unit_price: l.unit_price,
89                discount_amount: l.discount_amount,
90            })
91            .collect();
92        if let Some(d) = &order_discount {
93            Self::fold_order_discount(&mut compute_lines, d.percentage)?;
94        }
95        let computed = self
96            .compute_ticket(
97                &mut *tx,
98                sale.pos_profile_id,
99                sale.posting_at.date(),
100                super::pos_ports::PosTaxDocumentType::Invoice,
101                compute_lines,
102                tax,
103            )
104            .await?;
105        let id = Uuid::new_v4();
106        let r = self.invoices.insert_draft(&mut tx, &NewDraftInvoiceRow {
107            id,
108            client_uuid: None,
109            pos_profile_id: sale.pos_profile_id,
110            opening_entry_id: sale.opening_entry_id,
111            branch_id: sale.branch_id,
112            customer_id: sale.customer_id,
113            pos_table_id: sale.pos_table_id,
114            receipt_number: &sale.receipt_number,
115            posting_at: sale.posting_at,
116            net_total: computed.net_total,
117            tax_total: computed.tax_total,
118            grand_total: computed.grand_total,
119            rounding_adjustment: computed.rounding_adjustment,
120            rounded_total: computed.rounded_total,
121        }).await;
122        if let Err(e) = r {
123            return Err(map_invoice_dup(
124                e, &sale.receipt_number, None, sale.pos_table_id,
125                &self.invoices, &self.rpool(), None,
126            ).await);
127        }
128        for l in &computed.lines {
129            self.items.insert_line(&mut tx, &crate::infrastructure::persistence::NewInvoiceItemRow {
130                id: Uuid::new_v4(),
131                pos_invoice_id: id,
132                client_uuid: None,
133                item_id: l.item_id,
134                description: l.description.as_deref(),
135                course: l.course,
136                quantity: l.quantity,
137                unit_price: l.unit_price,
138                discount_amount: l.discount_amount,
139                net_amount: l.net_amount,
140                revenue_account_id: l.revenue_account_id,
141            }).await?;
142        }
143        tx.commit().await?;
144        Ok(id)
145    }
146
147    /// Ring a ticket whose prices are resolved by promo's CART pricer (the cart seam, ADR-002). POS
148    /// hands the whole basket (list prices + item dimensions + optional coupon) to the `CartPricingPort`;
149    /// promo returns per-line nets folding in line rules, order-total discounts, and bundles. POS maps
150    /// each net back to a `unit_price`/`discount_amount` pair so the compute core reproduces the
151    /// conserved cart total. Zero normal Cargo edge to promo.
152    pub async fn ring_sale_priced(
153        &self,
154        o: NewCartSale,
155        pricing: &dyn crate::application::service::pos_cart_pricing::CartPricingPort,
156        tax: &dyn PosTaxComputePort,
157    ) -> Result<Uuid, PosError> {
158        use crate::application::service::pos_cart_pricing::{CartPriceLine, CartPriceRequest};
159        if o.lines.is_empty() {
160            return Err(PosError::EmptyDocument);
161        }
162        let refs: Vec<Uuid> = o.lines.iter().map(|_| Uuid::new_v4()).collect();
163        let req = CartPriceRequest {
164            customer_id: o.customer_id,
165            customer_group_id: o.customer_group_id,
166            coupon_code: o.coupon_code.clone(),
167            lines: o
168                .lines
169                .iter()
170                .zip(&refs)
171                .map(|(l, r)| CartPriceLine {
172                    line_ref: *r,
173                    item_id: l.item_id,
174                    item_group_id: l.item_group_id,
175                    brand_id: l.brand_id,
176                    list_price: l.list_price,
177                    quantity: l.quantity,
178                })
179                .collect(),
180        };
181        let priced = pricing
182            .price_cart(&req)
183            .await
184            .map_err(|e| PosError::PricingRejected { code: e.code, message: e.message })?;
185
186        let mut lines = Vec::with_capacity(o.lines.len());
187        for (l, r) in o.lines.iter().zip(&refs) {
188            let pl = priced
189                .lines
190                .iter()
191                .find(|p| p.line_ref == *r)
192                .ok_or_else(|| PosError::PricingRejected {
193                    code: "pricing_line_missing".into(),
194                    message: "pricer omitted a line".into(),
195                })?;
196            let gross = super::pos_write_service::money(pl.unit_price * l.quantity);
197            let discount_amount = (gross - pl.net_line_total).max(Decimal::ZERO);
198            lines.push(NewSaleLine {
199                item_id: l.item_id,
200                revenue_account_id: l.revenue_account_id,
201                description: l.description.clone(),
202                course: l.course,
203                quantity: l.quantity,
204                unit_price: pl.unit_price,
205                discount_amount,
206            });
207        }
208        // Buy-X-get-Y: ring the free goods as zero-priced lines (they don't change the ticket total).
209        for rl in &priced.reward_lines {
210            lines.push(NewSaleLine {
211                item_id: rl.item_id,
212                revenue_account_id: None,
213                description: Some("promo reward (free)".into()),
214                course: None,
215                quantity: rl.quantity,
216                unit_price: Decimal::ZERO,
217                discount_amount: Decimal::ZERO,
218            });
219        }
220
221        self.ring_sale(NewSale {
222            pos_profile_id: o.pos_profile_id,
223            opening_entry_id: o.opening_entry_id,
224            branch_id: o.branch_id,
225            customer_id: o.customer_id,
226            pos_table_id: o.pos_table_id,
227            discount_id: o.discount_id,
228            receipt_number: o.receipt_number,
229            posting_at: o.posting_at,
230            lines,
231        }, tax)
232        .await
233    }
234}