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}