Skip to main content

backbone_pos/application/service/
pos_compute.rs

1//! The server-owned ticket computation (hand-authored, user-owned).
2//!
3//! An `impl PosWriteService` chunk over the vocabulary in [`super::pos_write_service`]. This is the
4//! ONE place a ticket's money is derived — `ring_sale`, `ring_sale_priced`, and the offline
5//! `sync_from_ui` all route through [`Self::compute_ticket`], so an online ticket and its offline
6//! replay can never price differently.
7//!
8//! The contract, in order:
9//!
10//! 1. **Client inputs are inputs only.** `unit_price`/`quantity`/`discount_amount` (and the promo
11//!    pricer's resolved nets) feed the computation; every total is derived here. No client-supplied
12//!    tax, grand total, or rounding step is ever read.
13//! 2. **Tax is document-grade.** The register's `tax_template_ids` (NOT the retired flat `tax_rate`
14//!    column) are expanded against the lines and resolved through the `PosTaxComputePort`. A register
15//!    with no templates refuses the ring outright — a zero-rated template is how a non-PKP register
16//!    expresses itself. Zero Cargo edge to the tax module lives here: the port is the only resolver.
17//! 3. **The port's nets win.** `PosTaxComputeResult::net_amounts` OVERWRITES POS's own per-line
18//!    rounding — a globally-rounding tax policy redistributes per-line cents so the journal balances,
19//!    and adopting those nets is what keeps Σ lines == header net.
20//! 4. **Cash rounding is register config.** `cash_rounding_strategy` + `cash_rounding_unit` on the
21//!    profile decide the pay-to total (IDR receipt rounding); the client no longer sends a step.
22//!
23//! Per the module's 4-layer rule this file holds no SQL — the register-config read lives on
24//! `PosProfileRepository`.
25//!
26//! Tenancy is composition-installed (ADR-0029): the register-config read rides the CALLER'S
27//! connection — every caller holds an open, scope-relayed transaction by the time it computes, so
28//! the read sees through the same fence the ticket insert will write under (a plain pool read here
29//! would see zero rows under a composed fence and refuse with a false 404).
30
31use rust_decimal::Decimal;
32use uuid::Uuid;
33
34use crate::infrastructure::persistence::TaxConfigRow;
35
36use super::pos_ports::{PosTaxComputePort, PosTaxComputeRequest, PosTaxDocumentType, PosTaxLineIn};
37use super::pos_write_service::{money, round_to, PosError, PosWriteService, TicketTotals};
38
39/// One line handed to the compute core — the neutral input shape shared by the online ring
40/// (`NewSaleLine`) and the offline replay (`SyncSaleLine`).
41pub struct ComputeLineIn {
42    pub item_id: Uuid,
43    pub revenue_account_id: Option<Uuid>,
44    pub description: Option<String>,
45    /// Course grouping (1 = starter, 2 = main, …) — a kitchen-routing label carried verbatim; it has
46    /// no money semantics and never influences the compute.
47    pub course: Option<i32>,
48    pub quantity: Decimal,
49    pub unit_price: Decimal,
50    pub discount_amount: Decimal,
51}
52
53/// One line as the compute core resolved it: the client's inputs + the SERVER-adopted net.
54pub struct ComputedLine {
55    pub item_id: Uuid,
56    pub revenue_account_id: Option<Uuid>,
57    pub description: Option<String>,
58    pub course: Option<i32>,
59    pub quantity: Decimal,
60    pub unit_price: Decimal,
61    pub discount_amount: Decimal,
62    /// The line's tax-excluded net AFTER the tax compute's redistribution — the only net that is
63    /// ever persisted (POS's own rounding is overwritten by the port result, by contract).
64    pub net_amount: Decimal,
65}
66
67/// A fully priced ticket: per-line adopted nets + the header money derived from them. The
68/// `TicketTotals` half (paid/change included) is finished once tenders are known — see
69/// [`Self::totals_with_tenders`].
70pub struct ComputedTicket {
71    pub lines: Vec<ComputedLine>,
72    pub net_total: Decimal,
73    pub tax_total: Decimal,
74    pub grand_total: Decimal,
75    pub rounding_adjustment: Decimal,
76    pub rounded_total: Decimal,
77}
78
79impl PosWriteService {
80    /// Derive a ticket's money from its inputs. See the module doc for the four-part contract.
81    ///
82    /// `document_type` tells the tax compute whether the document is a sale or a refund (the
83    /// repartition family differs); `on_date` is the ticket's posting date. `exec` is the caller's
84    /// open transaction — the register-config read must ride the same scope-relayed connection the
85    /// ticket insert will write under (ADR-0029).
86    pub(super) async fn compute_ticket(
87        &self,
88        exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
89        pos_profile_id: Uuid,
90        on_date: chrono::NaiveDate,
91        document_type: PosTaxDocumentType,
92        lines: Vec<ComputeLineIn>,
93        tax: &dyn PosTaxComputePort,
94    ) -> Result<ComputedTicket, PosError> {
95        if lines.is_empty() {
96            return Err(PosError::EmptyDocument);
97        }
98        // Register config: templates + rounding. Absent profile = 404; present-but-unconfigured
99        // templates = typed refusal (the register must be explicitly tax-configured — a zero-rated
100        // template is the non-PKP expression, so NULL/empty cannot silently mean "no tax").
101        let cfg: TaxConfigRow = self
102            .profiles
103            .fetch_tax_config(exec, pos_profile_id)
104            .await?
105            .ok_or(PosError::ProfileNotFound(pos_profile_id))?;
106        let templates = parse_template_ids(cfg.tax_template_ids.as_ref())
107            .ok_or(PosError::ProfileTaxTemplatesMissing(pos_profile_id))?;
108        if templates.is_empty() {
109            return Err(PosError::ProfileTaxTemplatesMissing(pos_profile_id));
110        }
111
112        // POS's own first-pass nets (money-rounded). These feed the tax compute; the port's
113        // redistribution OVERWRITES them per line below.
114        let mut first_pass: Vec<(ComputeLineIn, Decimal)> = Vec::with_capacity(lines.len());
115        for l in lines {
116            if l.quantity < Decimal::ZERO || l.unit_price < Decimal::ZERO || l.discount_amount < Decimal::ZERO {
117                return Err(PosError::NegativeAmount);
118            }
119            let gross = money(l.quantity * l.unit_price);
120            let net = gross - money(l.discount_amount);
121            if net < Decimal::ZERO {
122                return Err(PosError::NegativeAmount);
123            }
124            first_pass.push((l, net));
125        }
126
127        // Expand (line x template) into the compute request. Each line carries a correlation ref the
128        // result keys its per-line nets and components back on.
129        let refs: Vec<Uuid> = (0..first_pass.len()).map(|_| Uuid::new_v4()).collect();
130        let tax_lines: Vec<PosTaxLineIn> = first_pass
131            .iter()
132            .zip(&refs)
133            .flat_map(|((_, net), r)| {
134                templates
135                    .iter()
136                    .map(|t| PosTaxLineIn {
137                        line_ref: *r,
138                        template_id: *t,
139                        net_amount: *net,
140                    })
141                    .collect::<Vec<_>>()
142            })
143            .collect();
144        let result = tax
145            .compute_document(&PosTaxComputeRequest {
146                document_type,
147                on_date,
148                lines: tax_lines,
149            })
150            .await
151            .map_err(|e| PosError::TaxRejected { code: e.code, message: e.message })?;
152
153        // Adopt the port's per-line nets — the OVERWRITE half of the contract. A line the result
154        // omitted keeps POS's first pass (the port only redistributes what it was asked to).
155        let mut adopted: Vec<Decimal> = first_pass.iter().map(|(_, n)| *n).collect();
156        for (r, net) in &result.net_amounts {
157            if let Some(pos) = refs.iter().position(|x| x == r) {
158                adopted[pos] = *net;
159            }
160        }
161
162        let mut computed = Vec::with_capacity(first_pass.len());
163        let mut net_total = Decimal::ZERO;
164        for ((l, _), net) in first_pass.into_iter().zip(adopted) {
165            net_total += net;
166            computed.push(ComputedLine {
167                item_id: l.item_id,
168                revenue_account_id: l.revenue_account_id,
169                description: l.description,
170                course: l.course,
171                quantity: l.quantity,
172                unit_price: l.unit_price,
173                discount_amount: money(l.discount_amount),
174                net_amount: net,
175            });
176        }
177        let net_total = money(net_total);
178        // Tax total = Σ signed components (the same components the implementer books), so the header
179        // can never disagree with the per-line tax split that reaches the GL.
180        let mut tax_total = Decimal::ZERO;
181        for c in &result.components {
182            tax_total += c.tax_amount;
183        }
184        let tax_total = money(tax_total);
185        let grand = net_total + tax_total;
186        // Register-config cash rounding: `none` (or a zero unit) still money-rounds to 2dp; `half_up`
187        // steps to the configured unit (e.g. 100 for IDR receipts).
188        let step = match cfg.rounding_strategy.as_str() {
189            "half_up" if cfg.rounding_unit > Decimal::ZERO => cfg.rounding_unit,
190            _ => Decimal::ZERO,
191        };
192        let rounded = round_to(grand, step);
193        let rounding_adjustment = rounded - grand;
194        Ok(ComputedTicket {
195            lines: computed,
196            net_total,
197            tax_total,
198            grand_total: grand,
199            rounding_adjustment,
200            rounded_total: rounded,
201        })
202    }
203
204    /// Finish a [`TicketTotals`] once the tenders are known: the pay-to total is the ROUNDED total,
205    /// overpayment reads as change due. Shared by the ring path's tender step and the offline replay.
206    pub(super) fn totals_with_tenders(t: &ComputedTicket, paid_total: Decimal) -> TicketTotals {
207        let paid_total = money(paid_total);
208        let change_due = if paid_total > t.rounded_total {
209            paid_total - t.rounded_total
210        } else {
211            Decimal::ZERO
212        };
213        TicketTotals {
214            net_total: t.net_total,
215            tax_total: t.tax_total,
216            grand_total: t.grand_total,
217            rounding_adjustment: t.rounding_adjustment,
218            rounded_total: t.rounded_total,
219            paid_total,
220            change_due,
221        }
222    }
223}
224
225/// Parse the profile's `tax_template_ids` JSON into template ids. Accepts an array of uuid strings
226/// (or of `{id: ...}` objects, tolerantly); `None` = not JSON-shaped at all — the caller treats that
227/// exactly like NULL (unconfigured register, typed refusal).
228pub(super) fn parse_template_ids(v: Option<&serde_json::Value>) -> Option<Vec<Uuid>> {
229    let arr = v?.as_array()?;
230    let mut out = Vec::with_capacity(arr.len());
231    for e in arr {
232        let id = match e {
233            serde_json::Value::String(s) => Uuid::parse_str(s).ok(),
234            serde_json::Value::Object(o) => {
235                o.get("id").and_then(|i| i.as_str()).and_then(|s| Uuid::parse_str(s).ok())
236            }
237            _ => None,
238        };
239        // A malformed entry is a misconfigured register, not a partial tax application — refuse all.
240        out.push(id?);
241    }
242    Some(out)
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn template_parsing_accepts_strings_and_objects() {
251        let strings = serde_json::json!(["11111111-1111-1111-1111-111111111111"]);
252        assert_eq!(
253            parse_template_ids(Some(&strings)).unwrap(),
254            vec![Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()]
255        );
256        let objects = serde_json::json!([{ "id": "22222222-2222-2222-2222-222222222222" }]);
257        assert_eq!(parse_template_ids(Some(&objects)).unwrap().len(), 1);
258    }
259
260    #[test]
261    fn template_parsing_refuses_null_empty_and_malformed() {
262        assert_eq!(parse_template_ids(None), None); // NULL is unshaped -> caller refuses
263        assert_eq!(parse_template_ids(Some(&serde_json::json!([]))), Some(vec![]));
264        // A malformed entry refuses the whole set (None), never a partial application.
265        assert_eq!(parse_template_ids(Some(&serde_json::json!(["not-a-uuid"]))), None);
266        assert_eq!(parse_template_ids(Some(&serde_json::json!("not-an-array"))), None);
267    }
268
269    #[test]
270    fn totals_finish_change_due_on_overpayment() {
271        let t = ComputedTicket {
272            lines: vec![],
273            net_total: Decimal::from(1000),
274            tax_total: Decimal::from(110),
275            grand_total: Decimal::from(1110),
276            rounding_adjustment: Decimal::from(-10),
277            rounded_total: Decimal::from(1100),
278        };
279        let totals = PosWriteService::totals_with_tenders(&t, Decimal::from(1500));
280        assert_eq!(totals.change_due, Decimal::from(400));
281        assert_eq!(totals.paid_total, Decimal::from(1500));
282        // Underpayment carries no change.
283        let under = PosWriteService::totals_with_tenders(&t, Decimal::from(500));
284        assert_eq!(under.change_due, Decimal::ZERO);
285    }
286}