1use core::fmt;
22
23use rust_decimal::Decimal;
24
25use crate::operations::{PrintMode, PrintReceiptRequest, ReceiptResponse};
26
27mod label {
29 pub(super) const TIN: &str = "ՀՎՀՀ";
31 pub(super) const CRN: &str = "Գ/Հ";
33 pub(super) const SERIAL: &str = "ԱՀ";
35 pub(super) const RSEQ: &str = "ԿՀ";
37 pub(super) const DEPARTMENT: &str = "Բաժին";
39 pub(super) const TOTAL: &str = "Ընդամենը";
41 pub(super) const CASH: &str = "Առձեռն";
43 pub(super) const CARD: &str = "Անկանխիկ";
45 pub(super) const CHANGE: &str = "Մանր";
47 pub(super) const FISCAL: &str = "ՖԻՍԿԱԼ ՀԱՄԱՐ";
49 pub(super) const VERIFY: &str = "Ստուգիչ";
51 pub(super) const LOTTERY: &str = "Վիճակախաղ";
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum ReceiptLine {
60 Title(String),
62 Centered(String),
64 Text(String),
66 Field {
68 label: String,
70 value: String,
72 },
73 Item {
75 name: String,
77 amount: String,
79 },
80 Amount {
82 label: String,
84 value: String,
86 emphasize: bool,
88 },
89 Divider,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct ReceiptLayout {
97 pub lines: Vec<ReceiptLine>,
99}
100
101impl ReceiptLayout {
102 #[must_use]
106 pub fn to_plain_text(&self, width: usize) -> String {
107 let mut out = String::new();
108 for line in &self.lines {
109 match line {
110 ReceiptLine::Title(text) | ReceiptLine::Centered(text) => {
111 push_line(&mut out, ¢ered(text, width));
112 }
113 ReceiptLine::Text(text) => push_line(&mut out, text),
114 ReceiptLine::Field { label, value } => {
115 push_line(&mut out, &format!("{label}: {value}"));
116 }
117 ReceiptLine::Item { name, amount } => {
118 push_line(&mut out, &justified(name, amount, width));
119 }
120 ReceiptLine::Amount {
121 label,
122 value,
123 emphasize: _,
124 } => {
125 push_line(&mut out, &justified(label, value, width));
126 }
127 ReceiptLine::Divider => push_line(&mut out, &"-".repeat(width)),
128 }
129 }
130 out
131 }
132}
133
134impl fmt::Display for ReceiptLayout {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 f.write_str(&self.to_plain_text(DEFAULT_WIDTH))
139 }
140}
141
142pub const DEFAULT_WIDTH: usize = 32;
144
145#[must_use]
152pub fn format_receipt(request: &PrintReceiptRequest, response: &ReceiptResponse) -> ReceiptLayout {
153 let mut lines = Vec::new();
154
155 push_title(&mut lines, &response.taxpayer);
157 push_centered(&mut lines, &response.address);
158 push_field(&mut lines, label::TIN, &response.tin);
159 push_field(&mut lines, label::CRN, &response.crn);
160 push_field(&mut lines, label::SERIAL, &response.sn);
161 lines.push(ReceiptLine::Field {
162 label: label::RSEQ.to_owned(),
163 value: response.rseq.to_string(),
164 });
165 lines.push(ReceiptLine::Divider);
166
167 if request.mode == PrintMode::Products && !request.items.is_empty() {
169 for item in &request.items {
170 lines.push(ReceiptLine::Item {
171 name: item.product_name.clone(),
172 amount: money(item.qty * item.price),
173 });
174 }
175 } else if let Some(dep) = request.dep {
176 lines.push(ReceiptLine::Item {
177 name: format!("{} {dep}", label::DEPARTMENT),
178 amount: money(response.total),
179 });
180 }
181 lines.push(ReceiptLine::Divider);
182
183 lines.push(ReceiptLine::Amount {
185 label: label::TOTAL.to_owned(),
186 value: money(response.total),
187 emphasize: true,
188 });
189 push_amount(&mut lines, label::CASH, request.paid_amount);
190 push_amount(&mut lines, label::CARD, request.paid_amount_card);
191 push_amount(&mut lines, label::CHANGE, response.change);
192 lines.push(ReceiptLine::Divider);
193
194 if !response.fiscal.trim().is_empty() {
196 lines.push(ReceiptLine::Title(format!(
197 "{} {}",
198 label::FISCAL,
199 response.fiscal
200 )));
201 }
202 if let Some(verify) = meaningful(response.verification_number.as_deref()) {
203 push_field(&mut lines, label::VERIFY, verify);
204 }
205 if let Some(lottery) = meaningful(Some(&response.lottery)) {
206 push_field(&mut lines, label::LOTTERY, lottery);
207 }
208 if let Some(qr) = response.qr.as_deref().filter(|q| !q.trim().is_empty()) {
209 lines.push(ReceiptLine::Text(qr.to_owned()));
210 }
211
212 ReceiptLayout { lines }
213}
214
215fn money(value: Decimal) -> String {
218 format!("{:.2}", value.round_dp(2))
219}
220
221fn meaningful(value: Option<&str>) -> Option<&str> {
224 let trimmed = value.map(str::trim)?;
225 if trimmed.is_empty() || trimmed.chars().all(|c| c == '0') {
226 None
227 } else {
228 Some(trimmed)
229 }
230}
231
232fn push_title(lines: &mut Vec<ReceiptLine>, text: &str) {
233 if !text.trim().is_empty() {
234 lines.push(ReceiptLine::Title(text.trim().to_owned()));
235 }
236}
237
238fn push_centered(lines: &mut Vec<ReceiptLine>, text: &str) {
239 if !text.trim().is_empty() {
240 lines.push(ReceiptLine::Centered(text.trim().to_owned()));
241 }
242}
243
244fn push_field(lines: &mut Vec<ReceiptLine>, label: &str, value: &str) {
245 if !value.trim().is_empty() {
246 lines.push(ReceiptLine::Field {
247 label: label.to_owned(),
248 value: value.trim().to_owned(),
249 });
250 }
251}
252
253fn push_amount(lines: &mut Vec<ReceiptLine>, label: &str, value: Decimal) {
254 if value > Decimal::ZERO {
255 lines.push(ReceiptLine::Amount {
256 label: label.to_owned(),
257 value: money(value),
258 emphasize: false,
259 });
260 }
261}
262
263fn push_line(out: &mut String, text: &str) {
265 out.push_str(text);
266 out.push('\n');
267}
268
269fn centered(text: &str, width: usize) -> String {
271 let len = text.chars().count();
272 if len >= width {
273 return text.to_owned();
274 }
275 let pad = (width - len) / 2;
276 format!("{}{text}", " ".repeat(pad))
277}
278
279fn justified(left: &str, right: &str, width: usize) -> String {
282 let used = left.chars().count() + right.chars().count();
283 if used + 1 > width {
284 return format!("{left} {right}");
285 }
286 format!("{left}{}{right}", " ".repeat(width - used))
287}
288
289#[cfg(test)]
290mod tests {
291 use rust_decimal::Decimal;
292
293 use super::format_receipt;
294 use crate::operations::{PrintMode, PrintReceiptRequest, ReceiptItem, ReceiptResponse};
295
296 fn simple_request() -> PrintReceiptRequest {
298 PrintReceiptRequest {
299 mode: PrintMode::Simple,
300 paid_amount: Decimal::from(10),
301 paid_amount_card: Decimal::ZERO,
302 partial_amount: Decimal::ZERO,
303 pre_payment_amount: Decimal::ZERO,
304 dep: Some(1),
305 partner_tin: None,
306 use_ext_pos: false,
307 payment_system: None,
308 rrn: None,
309 terminal_id: None,
310 e_marks: Vec::new(),
311 items: Vec::new(),
312 }
313 }
314
315 fn live_response() -> ReceiptResponse {
317 ReceiptResponse {
318 rseq: 197,
319 crn: "51815332".to_owned(),
320 sn: "NCBB02223374".to_owned(),
321 tin: "00218811".to_owned(),
322 taxpayer: "«ՔՅՈՒ ՏԵՐՄԻՆԱԼ»".to_owned(),
323 address: "ԱՋԱՓՆՅԱԿ ԹԱՂԱՄԱՍ".to_owned(),
324 time: 1_781_361_108_000,
325 fiscal: "64048749".to_owned(),
326 lottery: "00000000".to_owned(),
327 prize: 0,
328 total: Decimal::from(10),
329 change: Decimal::ZERO,
330 qr: None,
331 emarks_count: Some("0".to_owned()),
332 verification_number: Some("0000000".to_owned()),
333 }
334 }
335
336 #[test]
337 fn renders_the_live_simple_sale_with_device_labels() {
338 let text = format_receipt(&simple_request(), &live_response()).to_plain_text(32);
339 assert!(text.contains("ՀՎՀՀ: 00218811"));
341 assert!(text.contains("Գ/Հ: 51815332"));
342 assert!(text.contains("ԱՀ: NCBB02223374"));
343 assert!(text.contains("ԿՀ: 197"));
344 assert!(text.contains("Բաժին 1"));
346 assert!(text.contains("Ընդամենը"));
348 assert!(text.contains("Առձեռն"));
349 assert!(!text.contains("Անկանխիկ"));
350 assert!(!text.contains("Մանր"));
351 assert!(text.contains("ՖԻՍԿԱԼ ՀԱՄԱՐ 64048749"));
353 assert!(!text.contains("Ստուգիչ"));
355 assert!(!text.contains("Վիճակախաղ"));
356 }
357
358 #[test]
359 fn renders_itemised_products_with_card_tender() {
360 let request = PrintReceiptRequest {
361 mode: PrintMode::Products,
362 dep: None,
363 paid_amount: Decimal::ZERO,
364 paid_amount_card: Decimal::from(40),
365 items: vec![ReceiptItem {
366 dep: 1,
367 qty: Decimal::from(2),
368 price: Decimal::from(20),
369 product_code: "56.0001".to_owned(),
370 product_name: "Կապուչինո".to_owned(),
371 adg_code: Some("2106".to_owned()),
372 unit: "հատ".to_owned(),
373 discount: None,
374 discount_kind: None,
375 additional_discount: None,
376 additional_discount_kind: None,
377 }],
378 ..simple_request()
379 };
380 let mut response = live_response();
381 response.total = Decimal::from(40);
382 let text = format_receipt(&request, &response).to_plain_text(32);
383 assert!(text.contains("Կապուչինո"));
384 assert!(text.contains("40.00"));
385 assert!(text.contains("Անկանխիկ"));
386 assert!(!text.contains("Բաժին"));
388 }
389
390 #[test]
391 fn surfaces_verification_lottery_and_qr_when_meaningful() {
392 let mut response = live_response();
393 response.verification_number = Some("128503".to_owned());
394 response.lottery = "00000002".to_owned();
395 response.qr = Some("TIN:00218811, CRN:51815332, FISCAL:64048749".to_owned());
396 let text = format_receipt(&simple_request(), &response).to_plain_text(32);
397 assert!(text.contains("Ստուգիչ: 128503"));
398 assert!(text.contains("Վիճակախաղ: 00000002"));
399 assert!(text.contains("CRN:51815332"));
400 }
401}