use core::fmt;
use rust_decimal::Decimal;
use crate::operations::{PrintMode, PrintReceiptRequest, ReceiptResponse};
mod label {
pub(super) const TIN: &str = "ՀՎՀՀ";
pub(super) const CRN: &str = "Գ/Հ";
pub(super) const SERIAL: &str = "ԱՀ";
pub(super) const RSEQ: &str = "ԿՀ";
pub(super) const DEPARTMENT: &str = "Բաժին";
pub(super) const TOTAL: &str = "Ընդամենը";
pub(super) const CASH: &str = "Առձեռն";
pub(super) const CARD: &str = "Անկանխիկ";
pub(super) const CHANGE: &str = "Մանր";
pub(super) const FISCAL: &str = "ՖԻՍԿԱԼ ՀԱՄԱՐ";
pub(super) const VERIFY: &str = "Ստուգիչ";
pub(super) const LOTTERY: &str = "Վիճակախաղ";
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReceiptLine {
Title(String),
Centered(String),
Text(String),
Field {
label: String,
value: String,
},
Item {
name: String,
amount: String,
},
Amount {
label: String,
value: String,
emphasize: bool,
},
Divider,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReceiptLayout {
pub lines: Vec<ReceiptLine>,
}
impl ReceiptLayout {
#[must_use]
pub fn to_plain_text(&self, width: usize) -> String {
let mut out = String::new();
for line in &self.lines {
match line {
ReceiptLine::Title(text) | ReceiptLine::Centered(text) => {
push_line(&mut out, ¢ered(text, width));
}
ReceiptLine::Text(text) => push_line(&mut out, text),
ReceiptLine::Field { label, value } => {
push_line(&mut out, &format!("{label}: {value}"));
}
ReceiptLine::Item { name, amount } => {
push_line(&mut out, &justified(name, amount, width));
}
ReceiptLine::Amount {
label,
value,
emphasize: _,
} => {
push_line(&mut out, &justified(label, value, width));
}
ReceiptLine::Divider => push_line(&mut out, &"-".repeat(width)),
}
}
out
}
}
impl fmt::Display for ReceiptLayout {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_plain_text(DEFAULT_WIDTH))
}
}
pub const DEFAULT_WIDTH: usize = 32;
#[must_use]
pub fn format_receipt(request: &PrintReceiptRequest, response: &ReceiptResponse) -> ReceiptLayout {
let mut lines = Vec::new();
push_title(&mut lines, &response.taxpayer);
push_centered(&mut lines, &response.address);
push_field(&mut lines, label::TIN, &response.tin);
push_field(&mut lines, label::CRN, &response.crn);
push_field(&mut lines, label::SERIAL, &response.sn);
lines.push(ReceiptLine::Field {
label: label::RSEQ.to_owned(),
value: response.rseq.to_string(),
});
lines.push(ReceiptLine::Divider);
if request.mode == PrintMode::Products && !request.items.is_empty() {
for item in &request.items {
lines.push(ReceiptLine::Item {
name: item.product_name.clone(),
amount: money(item.qty * item.price),
});
}
} else if let Some(dep) = request.dep {
lines.push(ReceiptLine::Item {
name: format!("{} {dep}", label::DEPARTMENT),
amount: money(response.total),
});
}
lines.push(ReceiptLine::Divider);
lines.push(ReceiptLine::Amount {
label: label::TOTAL.to_owned(),
value: money(response.total),
emphasize: true,
});
push_amount(&mut lines, label::CASH, request.paid_amount);
push_amount(&mut lines, label::CARD, request.paid_amount_card);
push_amount(&mut lines, label::CHANGE, response.change);
lines.push(ReceiptLine::Divider);
if !response.fiscal.trim().is_empty() {
lines.push(ReceiptLine::Title(format!(
"{} {}",
label::FISCAL,
response.fiscal
)));
}
if let Some(verify) = meaningful(response.verification_number.as_deref()) {
push_field(&mut lines, label::VERIFY, verify);
}
if let Some(lottery) = meaningful(Some(&response.lottery)) {
push_field(&mut lines, label::LOTTERY, lottery);
}
if let Some(qr) = response.qr.as_deref().filter(|q| !q.trim().is_empty()) {
lines.push(ReceiptLine::Text(qr.to_owned()));
}
ReceiptLayout { lines }
}
fn money(value: Decimal) -> String {
format!("{:.2}", value.round_dp(2))
}
fn meaningful(value: Option<&str>) -> Option<&str> {
let trimmed = value.map(str::trim)?;
if trimmed.is_empty() || trimmed.chars().all(|c| c == '0') {
None
} else {
Some(trimmed)
}
}
fn push_title(lines: &mut Vec<ReceiptLine>, text: &str) {
if !text.trim().is_empty() {
lines.push(ReceiptLine::Title(text.trim().to_owned()));
}
}
fn push_centered(lines: &mut Vec<ReceiptLine>, text: &str) {
if !text.trim().is_empty() {
lines.push(ReceiptLine::Centered(text.trim().to_owned()));
}
}
fn push_field(lines: &mut Vec<ReceiptLine>, label: &str, value: &str) {
if !value.trim().is_empty() {
lines.push(ReceiptLine::Field {
label: label.to_owned(),
value: value.trim().to_owned(),
});
}
}
fn push_amount(lines: &mut Vec<ReceiptLine>, label: &str, value: Decimal) {
if value > Decimal::ZERO {
lines.push(ReceiptLine::Amount {
label: label.to_owned(),
value: money(value),
emphasize: false,
});
}
}
fn push_line(out: &mut String, text: &str) {
out.push_str(text);
out.push('\n');
}
fn centered(text: &str, width: usize) -> String {
let len = text.chars().count();
if len >= width {
return text.to_owned();
}
let pad = (width - len) / 2;
format!("{}{text}", " ".repeat(pad))
}
fn justified(left: &str, right: &str, width: usize) -> String {
let used = left.chars().count() + right.chars().count();
if used + 1 > width {
return format!("{left} {right}");
}
format!("{left}{}{right}", " ".repeat(width - used))
}
#[cfg(test)]
mod tests {
use rust_decimal::Decimal;
use super::format_receipt;
use crate::operations::{PrintMode, PrintReceiptRequest, ReceiptItem, ReceiptResponse};
fn simple_request() -> PrintReceiptRequest {
PrintReceiptRequest {
mode: PrintMode::Simple,
paid_amount: Decimal::from(10),
paid_amount_card: Decimal::ZERO,
partial_amount: Decimal::ZERO,
pre_payment_amount: Decimal::ZERO,
dep: Some(1),
partner_tin: None,
use_ext_pos: false,
payment_system: None,
rrn: None,
terminal_id: None,
e_marks: Vec::new(),
items: Vec::new(),
}
}
fn live_response() -> ReceiptResponse {
ReceiptResponse {
rseq: 197,
crn: "51815332".to_owned(),
sn: "NCBB02223374".to_owned(),
tin: "00218811".to_owned(),
taxpayer: "«ՔՅՈՒ ՏԵՐՄԻՆԱԼ»".to_owned(),
address: "ԱՋԱՓՆՅԱԿ ԹԱՂԱՄԱՍ".to_owned(),
time: 1_781_361_108_000,
fiscal: "64048749".to_owned(),
lottery: "00000000".to_owned(),
prize: 0,
total: Decimal::from(10),
change: Decimal::ZERO,
qr: None,
emarks_count: Some("0".to_owned()),
verification_number: Some("0000000".to_owned()),
}
}
#[test]
fn renders_the_live_simple_sale_with_device_labels() {
let text = format_receipt(&simple_request(), &live_response()).to_plain_text(32);
assert!(text.contains("ՀՎՀՀ: 00218811"));
assert!(text.contains("Գ/Հ: 51815332"));
assert!(text.contains("ԱՀ: NCBB02223374"));
assert!(text.contains("ԿՀ: 197"));
assert!(text.contains("Բաժին 1"));
assert!(text.contains("Ընդամենը"));
assert!(text.contains("Առձեռն"));
assert!(!text.contains("Անկանխիկ"));
assert!(!text.contains("Մանր"));
assert!(text.contains("ՖԻՍԿԱԼ ՀԱՄԱՐ 64048749"));
assert!(!text.contains("Ստուգիչ"));
assert!(!text.contains("Վիճակախաղ"));
}
#[test]
fn renders_itemised_products_with_card_tender() {
let request = PrintReceiptRequest {
mode: PrintMode::Products,
dep: None,
paid_amount: Decimal::ZERO,
paid_amount_card: Decimal::from(40),
items: vec![ReceiptItem {
dep: 1,
qty: Decimal::from(2),
price: Decimal::from(20),
product_code: "56.0001".to_owned(),
product_name: "Կապուչինո".to_owned(),
adg_code: Some("2106".to_owned()),
unit: "հատ".to_owned(),
discount: None,
discount_kind: None,
additional_discount: None,
additional_discount_kind: None,
}],
..simple_request()
};
let mut response = live_response();
response.total = Decimal::from(40);
let text = format_receipt(&request, &response).to_plain_text(32);
assert!(text.contains("Կապուչինո"));
assert!(text.contains("40.00"));
assert!(text.contains("Անկանխիկ"));
assert!(!text.contains("Բաժին"));
}
#[test]
fn surfaces_verification_lottery_and_qr_when_meaningful() {
let mut response = live_response();
response.verification_number = Some("128503".to_owned());
response.lottery = "00000002".to_owned();
response.qr = Some("TIN:00218811, CRN:51815332, FISCAL:64048749".to_owned());
let text = format_receipt(&simple_request(), &response).to_plain_text(32);
assert!(text.contains("Ստուգիչ: 128503"));
assert!(text.contains("Վիճակախաղ: 00000002"));
assert!(text.contains("CRN:51815332"));
}
}