Skip to main content

hdm_am/operations/
mod.rs

1//! Per-operation request and response shapes, serialised as JSON per spec §4.5.
2//!
3//! Every field name matches the wire-format JSON key exactly (`paidAmount`, `crn`, etc.) so
4//! consumers can cross-reference the spec PDF without translation.
5//!
6//! Forward-compatibility invariants (see the crate-level docs): responses use `#[serde(default)]`
7//! on fields added in later spec revisions so older firmware (which omits them) still round-trips,
8//! and **no response type uses `#[serde(deny_unknown_fields)]`** so a newer firmware that adds
9//! response fields does not break parsing. Preserve both when adding operations.
10
11use crate::wire::OperationCode;
12use serde::{Deserialize, Serialize};
13
14/// Op 11 — cash drawer in/out (§4.5.8).
15pub mod cash;
16/// Op 7/8 — header, footer, and logo configuration (§4.6.3–4.6.4).
17pub mod config;
18/// Op 1 — operator and department directory (§4.5.1).
19pub mod directory;
20/// Op 12–16 — miscellaneous device operations (§4.6–4.9).
21pub mod misc;
22/// Op 4–6, 10 — receipt printing and returns (§4.5.4–4.5.7).
23pub mod receipt;
24/// Op 9 — fiscal reports (§4.6.2).
25pub mod reports;
26/// Op 2–3 — operator login and logout (§4.5.2–4.5.3).
27pub mod session;
28
29pub use cash::CashInOutRequest;
30pub use config::{SetupHeaderFooterRequest, SetupHeaderLogoRequest, TextAlign, TextLine};
31pub use directory::{
32    DepartmentInfo, ListOpsAndDepsRequest, ListOpsAndDepsResponse, OperatorInfo, TaxationKind,
33};
34pub use misc::{
35    DateTimeRequest, DateTimeResponse, HdmTimeSyncRequest, PaymentSystemEntry,
36    PaymentSystemsListRequest, PaymentSystemsListResponse, ReceiptSampleRequest,
37    SingleEmarkRequest,
38};
39pub use receipt::{
40    DiscountKind, GetReturnableReceiptRequest, PrintLastReceiptRequest, PrintMode,
41    PrintReceiptRequest, PrintReturnReceiptRequest, ReceiptItem, ReceiptResponse, ReturnItem,
42    ReturnReceiptResponse, ReturnableReceiptItem, ReturnableReceiptResponse,
43};
44pub use reports::{FiscalReportKind, FiscalReportRequest, ReportFilter};
45pub use session::{OperatorLoginRequest, OperatorLoginResponse, OperatorLogoutRequest};
46
47/// Marker response for operations that do not return a meaningful payload.
48///
49/// The wire-level response framing is still parsed (header + zero-byte or `{}` payload), but the
50/// caller gets no fields to consume.
51#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53#[serde(default)]
54pub struct EmptyResponse {}
55
56/// Internal trait coupling a request type to its operation code and response type. Implemented
57/// for each operation so that the high-level client API can stay short.
58pub trait Operation: Serialize {
59    /// Wire-level operation code.
60    const CODE: OperationCode;
61    /// Response type this operation expects back from the HDM.
62    type Response: for<'de> Deserialize<'de>;
63    /// Whether this operation uses the password-derived key (`true`) or the session key
64    /// (`false`). Per spec §4.4.3, only ops 1 and 2 use the password key.
65    const USES_PASSWORD_KEY: bool = false;
66    /// Whether this operation's *response* carries a secret (e.g. the session key from login) that
67    /// must never reach a log. When `true`, the client redacts the decrypted payload from its trace
68    /// output. Defaults to `false`.
69    const RESPONSE_IS_SECRET: bool = false;
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use crate::wire::OperationCode;
76    use rust_decimal::Decimal;
77
78    /// `PrintMode` serialises as its integer wire value (1/2/3).
79    #[test]
80    fn print_mode_wire_encoding() {
81        assert_eq!(serde_json::to_string(&PrintMode::Simple).unwrap(), "1");
82        assert_eq!(serde_json::to_string(&PrintMode::Products).unwrap(), "2");
83        assert_eq!(serde_json::to_string(&PrintMode::Prepayment).unwrap(), "3");
84    }
85
86    /// `DiscountKind` covers the full {1, 2, 4, 8, 16} set.
87    #[test]
88    fn discount_kind_wire_encoding() {
89        assert_eq!(serde_json::to_string(&DiscountKind::Percent).unwrap(), "1");
90        assert_eq!(
91            serde_json::to_string(&DiscountKind::UnitPriceReduction).unwrap(),
92            "2"
93        );
94        assert_eq!(
95            serde_json::to_string(&DiscountKind::LineTotalReduction).unwrap(),
96            "4"
97        );
98        assert_eq!(
99            serde_json::to_string(&DiscountKind::AdditionalPercent).unwrap(),
100            "8"
101        );
102        assert_eq!(
103            serde_json::to_string(&DiscountKind::AdditionalMonetary).unwrap(),
104            "16"
105        );
106    }
107
108    /// `TextAlign` matches spec values 1/2/3.
109    #[test]
110    fn text_align_wire_encoding() {
111        assert_eq!(serde_json::to_string(&TextAlign::Left).unwrap(), "1");
112        assert_eq!(serde_json::to_string(&TextAlign::Centered).unwrap(), "2");
113        assert_eq!(serde_json::to_string(&TextAlign::Right).unwrap(), "3");
114    }
115
116    /// `FiscalReportKind` matches spec values 1 (X) / 2 (Z).
117    #[test]
118    fn fiscal_report_kind_wire_encoding() {
119        assert_eq!(serde_json::to_string(&FiscalReportKind::X).unwrap(), "1");
120        assert_eq!(serde_json::to_string(&FiscalReportKind::Z).unwrap(), "2");
121    }
122
123    /// `TaxationKind::Unknown(N)` round-trips through JSON without loss.
124    #[test]
125    fn taxation_kind_preserves_unknown_codes() {
126        let unknown = TaxationKind::Unknown(99);
127        let json = serde_json::to_string(&unknown).unwrap();
128        assert_eq!(json, "99");
129        let parsed: TaxationKind = serde_json::from_str(&json).unwrap();
130        assert_eq!(parsed, TaxationKind::Unknown(99));
131    }
132
133    /// `TaxationKind` named variants round-trip cleanly.
134    #[test]
135    fn taxation_kind_named_variants_round_trip() {
136        for &kind in &[
137            TaxationKind::VatTaxable,
138            TaxationKind::NotVatTaxable,
139            TaxationKind::TurnoverTax,
140            TaxationKind::ProductionLicensee,
141            TaxationKind::Patented,
142            TaxationKind::FamilyBusiness,
143            TaxationKind::MicroBusiness,
144        ] {
145            let json = serde_json::to_string(&kind).unwrap();
146            let parsed: TaxationKind = serde_json::from_str(&json).unwrap();
147            assert_eq!(parsed, kind);
148        }
149    }
150
151    /// `partnerTin: null` is emitted explicitly rather than being skipped.
152    #[test]
153    fn partner_tin_serialises_as_null_when_absent() {
154        let req = PrintReceiptRequest {
155            mode: PrintMode::Simple,
156            paid_amount: Decimal::ZERO,
157            paid_amount_card: Decimal::ZERO,
158            partial_amount: Decimal::ZERO,
159            pre_payment_amount: Decimal::ZERO,
160            dep: Some(1),
161            partner_tin: None,
162            use_ext_pos: false,
163            payment_system: None,
164            rrn: None,
165            terminal_id: None,
166            e_marks: vec![],
167            items: vec![],
168        };
169        let json = serde_json::to_string(&req).unwrap();
170        assert!(
171            json.contains(r#""partnerTin":null"#),
172            "partnerTin should serialise as explicit null, got {json}"
173        );
174    }
175
176    /// All wire-renamed fields use the spec's exact JSON keys.
177    #[test]
178    fn print_receipt_request_uses_spec_wire_keys() {
179        let req = PrintReceiptRequest {
180            mode: PrintMode::Products,
181            paid_amount: Decimal::from(2200),
182            paid_amount_card: Decimal::from(10),
183            partial_amount: Decimal::ZERO,
184            pre_payment_amount: Decimal::ZERO,
185            dep: None,
186            partner_tin: None,
187            use_ext_pos: true,
188            payment_system: None,
189            rrn: Some("123456789012".to_owned()),
190            terminal_id: Some("12345678".to_owned()),
191            e_marks: vec!["xxx".to_owned()],
192            items: vec![ReceiptItem {
193                dep: 1,
194                qty: Decimal::from(3),
195                price: Decimal::from(1000),
196                product_code: "001".to_owned(),
197                product_name: "Pepsi".to_owned(),
198                adg_code: Some("0104".to_owned()),
199                unit: "litr".to_owned(),
200                discount: Some(Decimal::from(10)),
201                discount_kind: Some(DiscountKind::Percent),
202                additional_discount: Some(Decimal::from(500)),
203                additional_discount_kind: Some(DiscountKind::AdditionalMonetary),
204            }],
205        };
206        let json = serde_json::to_string(&req).unwrap();
207        for key in &[
208            r#""paidAmount":2200.0"#,
209            r#""paidAmountCard":10.0"#,
210            r#""partialAmount":0.0"#,
211            r#""prePaymentAmount":0.0"#,
212            r#""useExtPOS":true"#,
213            r#""terminalId":"12345678""#,
214            r#""eMarks":["xxx"]"#,
215            r#""productCode":"001""#,
216            r#""productName":"Pepsi""#,
217            r#""adgCode":"0104""#,
218            r#""discountType":1"#,
219            r#""additionalDiscount":500.0"#,
220            r#""additionalDiscountType":16"#,
221        ] {
222            assert!(json.contains(key), "missing key {key} in {json}");
223        }
224        // Rust-cased names must not appear.
225        for forbidden in &["paid_amount", "use_ext_pos", "terminal_id", "product_code"] {
226            assert!(!json.contains(forbidden), "unexpected snake_case in {json}");
227        }
228    }
229
230    /// Sample spec response (§4.5.4 Code Block 4) deserialises into `ReceiptResponse` cleanly.
231    #[test]
232    fn receipt_response_parses_spec_example() {
233        let json = r#"{
234            "rseq": 179,
235            "crn": "31008940",
236            "sn": "Q80414503833",
237            "tin": "00000019",
238            "taxpayer": "LUSARD",
239            "address": "Yerevan",
240            "time": 1490190340000,
241            "fiscal": "68287355",
242            "lottery": "00000002",
243            "prize": 0,
244            "total": 3000.0,
245            "change": 0.0,
246            "emarksCount": "1",
247            "verificationNumber": "128503",
248            "qr": "TIN:00000019..."
249        }"#;
250        let parsed: ReceiptResponse = serde_json::from_str(json).unwrap();
251        assert_eq!(parsed.rseq, 179);
252        assert_eq!(parsed.fiscal, "68287355");
253        assert_eq!(parsed.qr.as_deref(), Some("TIN:00000019..."));
254        assert_eq!(parsed.verification_number.as_deref(), Some("128503"));
255    }
256
257    /// Older HDM firmware that omits `qr`/`verificationNumber`/`emarksCount` must still parse.
258    #[test]
259    fn receipt_response_parses_v05_response_without_post_v05_fields() {
260        let json = r#"{
261            "rseq": 100,
262            "crn": "0001",
263            "sn": "SN",
264            "tin": "TIN",
265            "taxpayer": "TP",
266            "address": "Addr",
267            "time": 1,
268            "fiscal": "FN",
269            "lottery": "L",
270            "prize": 0,
271            "total": 50.0,
272            "change": 0.0
273        }"#;
274        let parsed: ReceiptResponse = serde_json::from_str(json).unwrap();
275        assert_eq!(parsed.rseq, 100);
276        assert!(parsed.qr.is_none());
277        assert!(parsed.verification_number.is_none());
278        assert!(parsed.emarks_count.is_none());
279    }
280
281    /// `Operation` trait correctly couples request type → op code → response type.
282    ///
283    /// The `USES_PASSWORD_KEY` constants are checked at compile time via `const { assert!(..) }`
284    /// blocks; any future drift would be a build failure, not a test failure.
285    #[test]
286    fn operation_couples_request_to_code_and_response() {
287        const { assert!(OperatorLoginRequest::USES_PASSWORD_KEY) };
288        const { assert!(!PrintReceiptRequest::USES_PASSWORD_KEY) };
289        const { assert!(ListOpsAndDepsRequest::USES_PASSWORD_KEY) };
290        const { assert!(!PaymentSystemsListRequest::USES_PASSWORD_KEY) };
291
292        assert_eq!(OperatorLoginRequest::CODE, OperationCode::OperatorLogin);
293        assert_eq!(PrintReceiptRequest::CODE, OperationCode::PrintReceipt);
294        assert_eq!(ListOpsAndDepsRequest::CODE, OperationCode::ListOpsAndDeps);
295        assert_eq!(
296            PaymentSystemsListRequest::CODE,
297            OperationCode::PaymentSystemsList
298        );
299    }
300
301    /// Pins the two return-flow operations to their exact wire codes from the spec's
302    /// operation-codes table (§4.4.1): print return = 6, get returnable (read-only lookup) = 10.
303    /// These were historically swapped because the spec describes them in section order
304    /// (§4.5.6 get, §4.5.7 print) which is the reverse of the code assignment.
305    #[test]
306    fn return_flow_codes_match_spec_table() {
307        assert_eq!(PrintReturnReceiptRequest::CODE as u8, 6);
308        assert_eq!(GetReturnableReceiptRequest::CODE as u8, 10);
309    }
310
311    /// Empty response deserialises from both `{}` and arbitrary objects (ignores unknown).
312    #[test]
313    fn empty_response_accepts_empty_and_unknown_fields() {
314        let _: EmptyResponse = serde_json::from_str("{}").unwrap();
315        let _: EmptyResponse = serde_json::from_str(r#"{"x":1,"y":"z"}"#).unwrap();
316    }
317
318    /// Op 15 payment-systems response handles the spec's wire shape with capitalised key.
319    #[test]
320    fn payment_systems_list_parses_spec_example() {
321        let json = r#"{
322            "PaymentSystems": [
323                { "code": 1, "name": "Card Payment" },
324                { "code": 13, "name": "IDRAM" }
325            ]
326        }"#;
327        let parsed: PaymentSystemsListResponse = serde_json::from_str(json).unwrap();
328        assert_eq!(parsed.payment_systems.len(), 2);
329        assert_eq!(parsed.payment_systems[0].code, 1);
330        assert_eq!(parsed.payment_systems[1].code, 13);
331        assert_eq!(parsed.payment_systems[1].name, "IDRAM");
332    }
333
334    /// Op 10 `ReturnableReceiptResponse` deserialises the spec's §4.5.6 Code Block 7 example.
335    /// This is the only coverage of that struct's `#[serde(default, with = "dec_opt")]` fields,
336    /// because the operation is unverifiable on the available test stand (always returns 503).
337    #[test]
338    fn returnable_receipt_parses_spec_code_block_7() {
339        let json = r#"{
340            "time": 1450260000, "type": 0, "ref": 0, "cid": 3,
341            "ta": 3000, "cash": 1000, "card": 1000, "ppu": 0, "ppa": 1000,
342            "saleType": 0, "pTin": "12345678",
343            "eMarks": ["aaa", "bbb"],
344            "totals": [
345                { "gc": "001", "gn": "Product1", "qty": 1, "p": 1000, "mu": "x",
346                  "rpid": 0, "dsc": null, "adsc": null, "dsct": null,
347                  "did": 1, "dt": 16.67, "dtm": 1, "t": 833.33, "tt": 1000.00 }
348            ]
349        }"#;
350        let r: ReturnableReceiptResponse = serde_json::from_str(json).unwrap();
351        // Numeric-as-number fields (cid/saleType/type/ref) deserialise via i64.
352        assert_eq!(r.cid, Some(3));
353        assert_eq!(r.sale_type, Some(0));
354        assert_eq!(r.kind, Some(0));
355        assert_eq!(r.returned_receipt, Some(0));
356        // Money fields go through dec_opt (JSON number -> Decimal).
357        assert_eq!(r.ta, Some(Decimal::from(3000)));
358        assert_eq!(r.partner_tin.as_deref(), Some("12345678"));
359        assert_eq!(r.e_marks.len(), 2);
360        // Fields absent from the example fall back to None via `default` (not a parse error).
361        assert_eq!(r.rseq, None);
362        assert_eq!(r.sub_type, None);
363        assert_eq!(r.returned_crn, None);
364        // Line item: present number, present null, and a fractional value all parse.
365        assert_eq!(r.totals.len(), 1);
366        let [item] = r.totals.as_slice() else {
367            panic!("expected one item")
368        };
369        assert_eq!(item.rpid, Some(0));
370        assert_eq!(item.qty, Some(Decimal::from(1)));
371        assert_eq!(item.discount, None); // dsc: null
372        assert_eq!(item.did, Some(1));
373        assert_eq!(item.vat_amount, Some(Decimal::new(1667, 2)));
374    }
375
376    /// An empty object must deserialise into an all-`None` `ReturnableReceiptResponse` — proves
377    /// every field is genuinely optional (a partial firmware response won't hard-error).
378    #[test]
379    fn returnable_receipt_parses_empty_object() {
380        let r: ReturnableReceiptResponse = serde_json::from_str("{}").unwrap();
381        assert!(r.rseq.is_none() && r.cid.is_none() && r.ta.is_none());
382        assert!(r.e_marks.is_empty() && r.totals.is_empty());
383    }
384
385    // --- Request deserialization (the input side the HTTP bridge relies on). ---
386
387    /// `PrintMode` round-trips through its integer wire value and rejects unknown codes.
388    #[test]
389    fn print_mode_deserialises_from_wire_value() {
390        assert_eq!(
391            serde_json::from_str::<PrintMode>("1").unwrap(),
392            PrintMode::Simple
393        );
394        assert_eq!(
395            serde_json::from_str::<PrintMode>("3").unwrap(),
396            PrintMode::Prepayment
397        );
398        assert!(serde_json::from_str::<PrintMode>("9").is_err());
399    }
400
401    /// `FiscalReportKind` round-trips through 1 (X) / 2 (Z) and rejects unknown codes.
402    #[test]
403    fn fiscal_report_kind_deserialises_from_wire_value() {
404        assert_eq!(
405            serde_json::from_str::<FiscalReportKind>("1").unwrap(),
406            FiscalReportKind::X
407        );
408        assert_eq!(
409            serde_json::from_str::<FiscalReportKind>("2").unwrap(),
410            FiscalReportKind::Z
411        );
412        assert!(serde_json::from_str::<FiscalReportKind>("0").is_err());
413    }
414
415    /// A minimal receipt JSON parses, with `eMarks`/`items` defaulting to empty when omitted.
416    #[test]
417    fn print_receipt_request_deserialises_with_defaults() {
418        let json = r#"{
419            "mode": 1, "paidAmount": 1000.0, "paidAmountCard": 0.0,
420            "partialAmount": 0.0, "prePaymentAmount": 0.0,
421            "useExtPOS": false, "dep": 1
422        }"#;
423        let req: PrintReceiptRequest = serde_json::from_str(json).unwrap();
424        assert_eq!(req.mode, PrintMode::Simple);
425        assert_eq!(req.paid_amount, Decimal::from(1000));
426        assert_eq!(req.dep, Some(1));
427        assert!(req.partner_tin.is_none());
428        assert!(req.e_marks.is_empty());
429        assert!(req.items.is_empty());
430    }
431
432    /// The `#[serde(flatten)] Option<ReportFilter>` round-trips: a filter key parses into the
433    /// right variant, and its absence yields `None` (the serde flatten+enum gotcha this guards).
434    #[test]
435    fn fiscal_report_request_filter_round_trips() {
436        let with_filter: FiscalReportRequest =
437            serde_json::from_str(r#"{"reportType":1,"deptId":5,"startDate":0,"endDate":0}"#)
438                .unwrap();
439        assert_eq!(with_filter.kind, FiscalReportKind::X);
440        assert_eq!(with_filter.filter, Some(ReportFilter::Department(5)));
441
442        let no_filter: FiscalReportRequest =
443            serde_json::from_str(r#"{"reportType":2,"startDate":0,"endDate":0}"#).unwrap();
444        assert_eq!(no_filter.kind, FiscalReportKind::Z);
445        assert_eq!(no_filter.filter, None);
446
447        // Build -> serialize -> deserialize preserves the chosen filter.
448        let original = FiscalReportRequest {
449            kind: FiscalReportKind::X,
450            filter: Some(ReportFilter::Cashier(7)),
451            start_date: 0,
452            end_date: 0,
453        };
454        let json = serde_json::to_string(&original).unwrap();
455        let back: FiscalReportRequest = serde_json::from_str(&json).unwrap();
456        assert_eq!(back.filter, Some(ReportFilter::Cashier(7)));
457    }
458}