Skip to main content

hdm_am/
format.rs

1//! Render a fiscal receipt as human-friendly text from the wire request/response pair.
2//!
3//! The HDM device prints the legally-binding fiscal receipt on its own printer (working mode P)
4//! and returns only structured identifiers; the body it printed is not otherwise recoverable. This
5//! module reconstructs a **faithful summary** of that receipt from what we sent
6//! ([`PrintReceiptRequest`] — items, departments, the cash/card split) and what came back
7//! ([`ReceiptResponse`] — the fiscal number, registration number, totals). It is for archival
8//! (store the text beside the raw JSON), operator/CLI display, and — on a working-mode-R device
9//! that does *not* self-print — as the source a caller rasterises onto its own printer.
10//!
11//! It is deliberately **not** a pixel-faithful clone of the government layout the device prints:
12//! the per-line VAT extraction and the department taxation captions depend on data outside the
13//! request/response pair (and on firmware), so they are omitted. The device's own printout remains
14//! the legal document; this is a record of it, not a replacement.
15//!
16//! The output is a width- and locale-agnostic [`ReceiptLayout`] of semantic [`ReceiptLine`]s. Time
17//! zone and paper width are presentation concerns owned by the caller: [`ReceiptLayout::to_plain_text`]
18//! renders a monospace block at a chosen column width, and a richer consumer can map the lines onto
19//! its own printer primitives.
20
21use core::fmt;
22
23use rust_decimal::Decimal;
24
25use crate::operations::{PrintMode, PrintReceiptRequest, ReceiptResponse};
26
27/// Receipt labels, exactly as the Armenian fiscal device prints them.
28mod label {
29    /// Taxpayer registration number (ՀՎՀՀ).
30    pub(super) const TIN: &str = "ՀՎՀՀ";
31    /// HDM registration number (Գրանցման համար).
32    pub(super) const CRN: &str = "Գ/Հ";
33    /// HDM hardware serial number (Արտադրական համար).
34    pub(super) const SERIAL: &str = "ԱՀ";
35    /// Receipt sequence number (Կտրոնի համար).
36    pub(super) const RSEQ: &str = "ԿՀ";
37    /// Department caption for a simple (lump-sum) receipt.
38    pub(super) const DEPARTMENT: &str = "Բաժին";
39    /// Grand total (Ընդամենը).
40    pub(super) const TOTAL: &str = "Ընդամենը";
41    /// Cash tendered (Առձեռն).
42    pub(super) const CASH: &str = "Առձեռն";
43    /// Cashless tendered (Անկանխիկ).
44    pub(super) const CARD: &str = "Անկանխիկ";
45    /// Change due (Մանր).
46    pub(super) const CHANGE: &str = "Մանր";
47    /// Fiscal number footer (ՖԻՍԿԱԼ ՀԱՄԱՐ).
48    pub(super) const FISCAL: &str = "ՖԻՍԿԱԼ ՀԱՄԱՐ";
49    /// Receipt verification number (Ստուգիչ համար).
50    pub(super) const VERIFY: &str = "Ստուգիչ";
51    /// Lottery ticket number (Վիճակախաղ).
52    pub(super) const LOTTERY: &str = "Վիճակախաղ";
53}
54
55/// One semantic line of a rendered receipt. Carries meaning, not geometry — width, alignment, and
56/// emphasis are applied by a renderer ([`ReceiptLayout::to_plain_text`] or a consumer's own).
57#[derive(Debug, Clone, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum ReceiptLine {
60    /// Centred, emphasised heading (the taxpayer name, the fiscal-number footer).
61    Title(String),
62    /// Centred plain text (the taxpayer address).
63    Centered(String),
64    /// Left-aligned plain text.
65    Text(String),
66    /// A labelled identifier: label on the left, value on the right.
67    Field {
68        /// The field caption.
69        label: String,
70        /// The field value.
71        value: String,
72    },
73    /// A goods line: name on the left, line total on the right.
74    Item {
75        /// Item display name (or the department caption for a simple receipt).
76        name: String,
77        /// Formatted line amount.
78        amount: String,
79    },
80    /// A money row: caption on the left, amount on the right. `emphasize` marks the grand total.
81    Amount {
82        /// The amount caption.
83        label: String,
84        /// Formatted amount.
85        value: String,
86        /// Whether this row is the emphasised grand total.
87        emphasize: bool,
88    },
89    /// A horizontal separator spanning the receipt width.
90    Divider,
91}
92
93/// A complete receipt as an ordered list of semantic lines. Width- and locale-agnostic; render it
94/// with [`ReceiptLayout::to_plain_text`] or consume the lines directly.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct ReceiptLayout {
97    /// The receipt's lines, top to bottom.
98    pub lines: Vec<ReceiptLine>,
99}
100
101impl ReceiptLayout {
102    /// Render the layout as a monospace text block `width` columns wide. Centring and right-aligning
103    /// count Unicode scalar values, not bytes, so the Armenian script lays out correctly. Lines that
104    /// do not fit fall back to a single space between the two halves rather than truncating.
105    #[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, &centered(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
134/// Renders the default 32-column monospace block — the common 80 mm thermal width for the Armenian
135/// raster font. Use [`ReceiptLayout::to_plain_text`] for any other width.
136impl 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
142/// The default column width [`Display`](fmt::Display) renders at (80 mm thermal, Armenian raster).
143pub const DEFAULT_WIDTH: usize = 32;
144
145/// Build a faithful receipt summary from the request we sent and the response the device returned.
146///
147/// Empty response fields (older firmware, or a field the device leaves blank) are skipped rather
148/// than printed empty. For a [`PrintMode::Products`] receipt the goods come from the request's
149/// `items`; for a simple or prepayment receipt the body is a single department line carrying the
150/// response total.
151#[must_use]
152pub fn format_receipt(request: &PrintReceiptRequest, response: &ReceiptResponse) -> ReceiptLayout {
153    let mut lines = Vec::new();
154
155    // Header — taxpayer identity and the device's fiscal registration.
156    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    // Body — the goods, or a single department line for a lump-sum receipt.
168    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    // Totals — the grand total and how it was tendered.
184    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    // Footer — the legally-binding fiscal number and the optional verification / lottery / QR.
195    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
215/// Format a monetary [`Decimal`] with exactly two fractional digits, AMD-style (no symbol — the
216/// device prints amounts bare).
217fn money(value: Decimal) -> String {
218    format!("{:.2}", value.round_dp(2))
219}
220
221/// A response string is "meaningful" when it is present, non-empty, and not the all-zero
222/// placeholder some devices return for an unused verification/lottery slot.
223fn 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
263/// Append `text` and a newline to `out`.
264fn push_line(out: &mut String, text: &str) {
265    out.push_str(text);
266    out.push('\n');
267}
268
269/// Centre `text` within `width` columns by Unicode scalar count; left-justify if it overflows.
270fn 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
279/// Place `left` and `right` at the two ends of a `width`-column line; if they do not fit, separate
280/// them with a single space.
281fn 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    /// A simple (lump-sum) cash request: 10 AMD in department 1 — the live test sale.
297    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    /// The exact response the live Newland device returned for that sale (rseq 197).
316    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        // The registration number prints under Գ/Հ (crn), the serial under ԱՀ (sn) — not swapped.
340        assert!(text.contains("ՀՎՀՀ: 00218811"));
341        assert!(text.contains("Գ/Հ: 51815332"));
342        assert!(text.contains("ԱՀ: NCBB02223374"));
343        assert!(text.contains("ԿՀ: 197"));
344        // Simple-mode body: a single department line carrying the total.
345        assert!(text.contains("Բաժին 1"));
346        // The grand total and the cash tender; card / change rows are absent (both zero).
347        assert!(text.contains("Ընդամենը"));
348        assert!(text.contains("Առձեռն"));
349        assert!(!text.contains("Անկանխիկ"));
350        assert!(!text.contains("Մանր"));
351        // The legally-binding fiscal number.
352        assert!(text.contains("ՖԻՍԿԱԼ ՀԱՄԱՐ 64048749"));
353        // All-zero verification + lottery placeholders are suppressed, not printed as zeros.
354        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        // No simple-mode department line when we have itemised goods.
387        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}