use rust_decimal::Decimal;
use uuid::Uuid;
use crate::infrastructure::persistence::TaxConfigRow;
use super::pos_ports::{PosTaxComputePort, PosTaxComputeRequest, PosTaxDocumentType, PosTaxLineIn};
use super::pos_write_service::{money, round_to, PosError, PosWriteService, TicketTotals};
pub struct ComputeLineIn {
pub item_id: Uuid,
pub revenue_account_id: Option<Uuid>,
pub description: Option<String>,
pub course: Option<i32>,
pub quantity: Decimal,
pub unit_price: Decimal,
pub discount_amount: Decimal,
}
pub struct ComputedLine {
pub item_id: Uuid,
pub revenue_account_id: Option<Uuid>,
pub description: Option<String>,
pub course: Option<i32>,
pub quantity: Decimal,
pub unit_price: Decimal,
pub discount_amount: Decimal,
pub net_amount: Decimal,
}
pub struct ComputedTicket {
pub lines: Vec<ComputedLine>,
pub net_total: Decimal,
pub tax_total: Decimal,
pub grand_total: Decimal,
pub rounding_adjustment: Decimal,
pub rounded_total: Decimal,
}
impl PosWriteService {
pub(super) async fn compute_ticket(
&self,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
pos_profile_id: Uuid,
on_date: chrono::NaiveDate,
document_type: PosTaxDocumentType,
lines: Vec<ComputeLineIn>,
tax: &dyn PosTaxComputePort,
) -> Result<ComputedTicket, PosError> {
if lines.is_empty() {
return Err(PosError::EmptyDocument);
}
let cfg: TaxConfigRow = self
.profiles
.fetch_tax_config(exec, pos_profile_id)
.await?
.ok_or(PosError::ProfileNotFound(pos_profile_id))?;
let templates = parse_template_ids(cfg.tax_template_ids.as_ref())
.ok_or(PosError::ProfileTaxTemplatesMissing(pos_profile_id))?;
if templates.is_empty() {
return Err(PosError::ProfileTaxTemplatesMissing(pos_profile_id));
}
let mut first_pass: Vec<(ComputeLineIn, Decimal)> = Vec::with_capacity(lines.len());
for l in lines {
if l.quantity < Decimal::ZERO || l.unit_price < Decimal::ZERO || l.discount_amount < Decimal::ZERO {
return Err(PosError::NegativeAmount);
}
let gross = money(l.quantity * l.unit_price);
let net = gross - money(l.discount_amount);
if net < Decimal::ZERO {
return Err(PosError::NegativeAmount);
}
first_pass.push((l, net));
}
let refs: Vec<Uuid> = (0..first_pass.len()).map(|_| Uuid::new_v4()).collect();
let tax_lines: Vec<PosTaxLineIn> = first_pass
.iter()
.zip(&refs)
.flat_map(|((_, net), r)| {
templates
.iter()
.map(|t| PosTaxLineIn {
line_ref: *r,
template_id: *t,
net_amount: *net,
})
.collect::<Vec<_>>()
})
.collect();
let result = tax
.compute_document(&PosTaxComputeRequest {
document_type,
on_date,
lines: tax_lines,
})
.await
.map_err(|e| PosError::TaxRejected { code: e.code, message: e.message })?;
let mut adopted: Vec<Decimal> = first_pass.iter().map(|(_, n)| *n).collect();
for (r, net) in &result.net_amounts {
if let Some(pos) = refs.iter().position(|x| x == r) {
adopted[pos] = *net;
}
}
let mut computed = Vec::with_capacity(first_pass.len());
let mut net_total = Decimal::ZERO;
for ((l, _), net) in first_pass.into_iter().zip(adopted) {
net_total += net;
computed.push(ComputedLine {
item_id: l.item_id,
revenue_account_id: l.revenue_account_id,
description: l.description,
course: l.course,
quantity: l.quantity,
unit_price: l.unit_price,
discount_amount: money(l.discount_amount),
net_amount: net,
});
}
let net_total = money(net_total);
let mut tax_total = Decimal::ZERO;
for c in &result.components {
tax_total += c.tax_amount;
}
let tax_total = money(tax_total);
let grand = net_total + tax_total;
let step = match cfg.rounding_strategy.as_str() {
"half_up" if cfg.rounding_unit > Decimal::ZERO => cfg.rounding_unit,
_ => Decimal::ZERO,
};
let rounded = round_to(grand, step);
let rounding_adjustment = rounded - grand;
Ok(ComputedTicket {
lines: computed,
net_total,
tax_total,
grand_total: grand,
rounding_adjustment,
rounded_total: rounded,
})
}
pub(super) fn totals_with_tenders(t: &ComputedTicket, paid_total: Decimal) -> TicketTotals {
let paid_total = money(paid_total);
let change_due = if paid_total > t.rounded_total {
paid_total - t.rounded_total
} else {
Decimal::ZERO
};
TicketTotals {
net_total: t.net_total,
tax_total: t.tax_total,
grand_total: t.grand_total,
rounding_adjustment: t.rounding_adjustment,
rounded_total: t.rounded_total,
paid_total,
change_due,
}
}
}
pub(super) fn parse_template_ids(v: Option<&serde_json::Value>) -> Option<Vec<Uuid>> {
let arr = v?.as_array()?;
let mut out = Vec::with_capacity(arr.len());
for e in arr {
let id = match e {
serde_json::Value::String(s) => Uuid::parse_str(s).ok(),
serde_json::Value::Object(o) => {
o.get("id").and_then(|i| i.as_str()).and_then(|s| Uuid::parse_str(s).ok())
}
_ => None,
};
out.push(id?);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn template_parsing_accepts_strings_and_objects() {
let strings = serde_json::json!(["11111111-1111-1111-1111-111111111111"]);
assert_eq!(
parse_template_ids(Some(&strings)).unwrap(),
vec![Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()]
);
let objects = serde_json::json!([{ "id": "22222222-2222-2222-2222-222222222222" }]);
assert_eq!(parse_template_ids(Some(&objects)).unwrap().len(), 1);
}
#[test]
fn template_parsing_refuses_null_empty_and_malformed() {
assert_eq!(parse_template_ids(None), None); assert_eq!(parse_template_ids(Some(&serde_json::json!([]))), Some(vec![]));
assert_eq!(parse_template_ids(Some(&serde_json::json!(["not-a-uuid"]))), None);
assert_eq!(parse_template_ids(Some(&serde_json::json!("not-an-array"))), None);
}
#[test]
fn totals_finish_change_due_on_overpayment() {
let t = ComputedTicket {
lines: vec![],
net_total: Decimal::from(1000),
tax_total: Decimal::from(110),
grand_total: Decimal::from(1110),
rounding_adjustment: Decimal::from(-10),
rounded_total: Decimal::from(1100),
};
let totals = PosWriteService::totals_with_tenders(&t, Decimal::from(1500));
assert_eq!(totals.change_due, Decimal::from(400));
assert_eq!(totals.paid_total, Decimal::from(1500));
let under = PosWriteService::totals_with_tenders(&t, Decimal::from(500));
assert_eq!(under.change_due, Decimal::ZERO);
}
}