mrn_generator/
lib.rs

1use chrono::{Datelike, Utc};
2use rand::{distributions::Alphanumeric, prelude::Distribution};
3use thiserror::Error;
4
5#[derive(Error, Debug, PartialEq)]
6pub enum MrnGeneratorError {
7    #[error("{0} is not a valid country code, it should be exactly two characters (e.g. 'IT')")]
8    CountryCodeLength(String),
9    #[error("{0} is not a valid procedure category")]
10    InvalidProcedureCategory(String),
11    #[error("{procedure_category}-{combination} is not a valid procedure category combination")]
12    InvalidProcedureCategoryCombination {
13        procedure_category: String,
14        combination: String,
15    },
16    #[error("{0} is not an alphanumeric")]
17    NotAlphanumeric(char),
18}
19
20/// Returns a valid MRN given a country code
21/// 
22/// ## Example
23/// ```
24/// use mrn_generator::generate_random_mrn; 
25/// 
26/// let mrn = generate_random_mrn("DK", None, Some("004700")).unwrap();
27/// println!("{mrn}");
28/// ```
29pub fn generate_random_mrn(
30    country_code: &str,
31    procedure: Option<Procedure>,
32    declaration_office: Option<&str>,
33) -> Result<String, MrnGeneratorError> {
34    use MrnGeneratorError::*;
35
36    let curr_year: String = Utc::now().year().to_string().chars().skip(2).collect();
37
38    let random_str_len = 14 - declaration_office.map_or(0, |decoffice| decoffice.len());
39
40    let random_str: String = Alphanumeric
41        .sample_iter(&mut rand::thread_rng())
42        .take(random_str_len)
43        .map(|c| c.to_ascii_uppercase() as char)
44        .collect();
45
46    if country_code.len() != 2 {
47        return Err(CountryCodeLength(country_code.to_string()));
48    }
49
50    let mut mrn = format!(
51        "{}{}{}{}",
52        curr_year,
53        capitalize(country_code),
54        declaration_office.unwrap_or(""),
55        random_str
56    );
57
58    if let Some(procedure) = procedure {
59        let proctgr_char = procecure_category_to_char(procedure).to_string();
60
61        // Replace n-1 char with regime char
62        mrn.replace_range(16..17, &proctgr_char);
63    }
64
65    // Check MRN, and replace last character if invalid
66    let last_digit = is_mrn_valid(&mrn)?;
67
68    if let Some(last_digit) = last_digit {
69        Ok(replace_last_char(&mrn, last_digit))
70    } else {
71        Ok(mrn)
72    }
73}
74
75/// Returns None if MRN is valid, and correct last character if it's invalid
76/// 
77/// ## Example
78/// ```
79/// use mrn_generator::{generate_random_mrn, is_mrn_valid}; 
80/// 
81/// let mrn = generate_random_mrn("DK", None, Some("004700")).unwrap();
82/// assert_eq!(None, is_mrn_valid(&mrn).unwrap());
83/// ```
84pub fn is_mrn_valid(mrn: &str) -> Result<Option<char>, MrnGeneratorError> {
85    let mut mrn_iter = mrn.chars();
86    let last_digit = mrn_iter.next_back().unwrap();
87
88    let mrn_temp: String = mrn_iter.collect();
89
90    // Multiply each char value with it's power of 2 and sum them
91    let multiplied_sum: u32 = mrn_temp
92        .chars()
93        .zip(0..mrn_temp.len())
94        .map(|(c, m)| (check_character_value(c).map(|value| (value as u32) << m)))
95        .collect::<Result<Vec<u32>, MrnGeneratorError>>()?
96        .iter()
97        .sum();
98
99    let check_digit: u8 = (multiplied_sum % 11).try_into().unwrap();
100    Ok(check_remainder_value(check_digit, last_digit))
101}
102
103/// Procedure types
104#[derive(Debug, PartialEq, Clone, Copy)]
105pub enum Procedure {
106    ExportOnly,
107    ExportAndExitSummaryDeclaration,
108    ExitSummaryDeclarationOnly,
109    ReExportNotification,
110    DispatchOfGoodsInRelationWithSpecialFiscalTerritories,
111    TransitDeclarationOnly,
112    TransitDeclarationAndExitSummaryDeclaration,
113    TransitDeclarationAndEntrySummaryDeclaration,
114    ProofOfTheCustomsStatusOfUnionGoods,
115    ImportDeclarationOnly,
116    ImportDeclarationAndEntrySummaryDeclaration,
117    EntrySummaryDeclarationOnly,
118    TemporaryStorageDeclaration,
119    IntroductionOfGoodsInRelationWithSpecialFiscalTerritories,
120    TemporaryStorageDeclarationAndEntrySummaryDeclaration,
121}
122
123/// Maps procedure category to a corresponding character
124pub fn procecure_category_to_char(procedure: Procedure) -> char {
125    match procedure {
126        Procedure::ExportOnly => 'A',
127        Procedure::ExportAndExitSummaryDeclaration => 'B',
128        Procedure::ExitSummaryDeclarationOnly => 'C',
129        Procedure::ReExportNotification => 'D',
130        Procedure::DispatchOfGoodsInRelationWithSpecialFiscalTerritories => 'E',
131        Procedure::TransitDeclarationOnly => 'J',
132        Procedure::TransitDeclarationAndExitSummaryDeclaration => 'K',
133        Procedure::TransitDeclarationAndEntrySummaryDeclaration => 'L',
134        Procedure::ProofOfTheCustomsStatusOfUnionGoods => 'M',
135        Procedure::ImportDeclarationOnly => 'R',
136        Procedure::ImportDeclarationAndEntrySummaryDeclaration => 'S',
137        Procedure::EntrySummaryDeclarationOnly => 'T',
138        Procedure::TemporaryStorageDeclaration => 'U',
139        Procedure::IntroductionOfGoodsInRelationWithSpecialFiscalTerritories => 'V',
140        Procedure::TemporaryStorageDeclarationAndEntrySummaryDeclaration => 'W',
141    }
142}
143
144/// Matches a procedure category code (optionally combined with another one) and returns
145/// the corresponding customs procedure
146pub fn match_procedure(
147    proctgr: &str,
148    combined: Option<&str>,
149) -> Result<Procedure, MrnGeneratorError> {
150    use MrnGeneratorError::*;
151
152    let exit_combined = ["A"];
153    let entry_combined = ["F"];
154    match proctgr {
155        "B1" | "B2" | "B3" | "C1" if combined.is_none() => Ok(Procedure::ExportOnly),
156        "B1" | "B2" | "B3" | "C1" if combined.is_some_and(|c| exit_combined.contains(&c)) => {
157            Ok(Procedure::ExportAndExitSummaryDeclaration)
158        }
159        "A1" | "A2" => Ok(Procedure::ExitSummaryDeclarationOnly),
160        "A3" => Ok(Procedure::ReExportNotification),
161        "B4" => Ok(Procedure::DispatchOfGoodsInRelationWithSpecialFiscalTerritories),
162        "D1" | "D2" | "D3" if combined.is_none() => Ok(Procedure::TransitDeclarationOnly),
163        "D1" | "D2" | "D3" if combined.is_some_and(|c| exit_combined.contains(&c)) => {
164            Ok(Procedure::TransitDeclarationAndExitSummaryDeclaration)
165        }
166        "D1" | "D2" | "D3" if combined.is_some_and(|c| entry_combined.contains(&c)) => {
167            Ok(Procedure::TransitDeclarationAndEntrySummaryDeclaration)
168        }
169        "E1" | "E2" => Ok(Procedure::ProofOfTheCustomsStatusOfUnionGoods),
170        "H1" | "H2" | "H3" | "H4" | "H6" | "I1" if combined.is_none() => {
171            Ok(Procedure::ImportDeclarationOnly)
172        }
173        "H1" | "H2" | "H3" | "H4" | "H6" | "I1"
174            if combined.is_some_and(|c| entry_combined.contains(&c)) =>
175        {
176            Ok(Procedure::ImportDeclarationAndEntrySummaryDeclaration)
177        }
178        "F1a" | "F1b" | "F1c" | "F1d" | "F2a" | "F2b" | "F2c" | "F2d" | "F3a" | "F3b" | "F4a"
179        | "F4b" | "F4c" | "F5" => Ok(Procedure::EntrySummaryDeclarationOnly),
180        "H5" => Ok(Procedure::IntroductionOfGoodsInRelationWithSpecialFiscalTerritories),
181        "G4" if combined.is_none() => Ok(Procedure::TemporaryStorageDeclaration),
182        "G4" if combined.is_some_and(|c| entry_combined.contains(&c)) => {
183            Ok(Procedure::TemporaryStorageDeclarationAndEntrySummaryDeclaration)
184        }
185        _ => {
186            if let Some(c) = combined {
187                Err(InvalidProcedureCategoryCombination {
188                    procedure_category: proctgr.to_string(),
189                    combination: c.to_string(),
190                })
191            } else {
192                Err(InvalidProcedureCategory(proctgr.to_string()))
193            }
194        }
195    }
196}
197
198/// Capitalizes string
199fn capitalize(s: &str) -> String {
200    s.chars().map(|c| c.to_ascii_uppercase()).collect()
201}
202
203/// Replaces last character of string with new character
204fn replace_last_char(s: &str, c: char) -> String {
205    let mut new_str = s.to_string();
206    new_str.pop();
207    new_str.push(c);
208    new_str
209}
210
211/// Remainder values according to tables in ISO 6346
212pub fn check_remainder_value(check_digit: u8, last_digit: char) -> Option<char> {
213    if check_digit % 10 != last_digit as u8 - 48 {
214        char::from_digit((check_digit % 10) as u32, 10)
215    } else {
216        None
217    }
218}
219
220/// Character values according to tables in ISO 6346
221pub fn check_character_value(c: char) -> Result<u8, MrnGeneratorError> {
222    if c.is_ascii_digit() {
223        return Ok(c as u8 - 48);
224    }
225    if c.is_alphabetic() {
226        if c == 'A' {
227            return Ok(10);
228        } else if ('B'..='K').contains(&c) {
229            return Ok(c as u8 - 54);
230        } else if ('L'..='U').contains(&c) {
231            return Ok(c as u8 - 53);
232        } else {
233            return Ok(c as u8 - 52);
234        }
235    }
236
237    Err(MrnGeneratorError::NotAlphanumeric(c))
238}
239
240#[cfg(test)]
241mod tests {
242
243    use super::*;
244
245    #[test]
246    fn generate_random_mrn_test() {
247        let mrn = generate_random_mrn("DK", Some(Procedure::ExportOnly), None).unwrap();
248
249        let country_code: String = mrn.chars().skip(2).take(2).collect();
250        let actual_year: String = mrn.chars().take(2).collect();
251        let expected_year: String = Utc::now().year().to_string().chars().skip(2).collect();
252        let procedure_char: char = mrn.chars().nth(16).unwrap();
253        assert_eq!(18, mrn.len());
254        assert_eq!(expected_year, actual_year);
255        assert_eq!('A', procedure_char);
256        assert_eq!("DK".to_string(), country_code);
257        assert_eq!(None, is_mrn_valid(&mrn).unwrap());
258    }
259
260    #[test]
261    fn generate_random_mrn_test_without_procedure() {
262        let mrn = generate_random_mrn("DK", None, None).unwrap();
263
264        let country_code: String = mrn.chars().skip(2).take(2).collect();
265        let actual_year: String = mrn.chars().take(2).collect();
266        let expected_year: String = Utc::now().year().to_string().chars().skip(2).collect();
267        assert_eq!(18, mrn.len());
268        assert_eq!(expected_year, actual_year);
269        assert_eq!("DK".to_string(), country_code);
270        assert_eq!(None, is_mrn_valid(&mrn).unwrap());
271    }
272
273    #[test]
274    fn generate_random_mrn_test_with_declaration_office() {
275        let mrn = generate_random_mrn("DK", None, Some("004700")).unwrap();
276
277        let country_code: String = mrn.chars().skip(2).take(2).collect();
278        let actual_year: String = mrn.chars().take(2).collect();
279        let declaration_office: String = mrn.chars().skip(4).take(6).collect();
280        let expected_year: String = Utc::now().year().to_string().chars().skip(2).collect();
281        assert_eq!(18, mrn.len());
282        assert_eq!(expected_year, actual_year);
283        assert_eq!("DK".to_string(), country_code);
284        assert_eq!("004700".to_string(), declaration_office);
285        assert_eq!(None, is_mrn_valid(&mrn).unwrap());
286    }
287
288    #[test]
289    fn is_mrn_valid_test() {
290        assert_eq!(None, is_mrn_valid("22ITZXBZYUTJFLJXK6").unwrap());
291        assert_eq!(Some('1'), is_mrn_valid("22DK1V0QQK2S6J7TU2").unwrap());
292    }
293
294    #[test]
295    fn procedure_matched_test() {
296        assert_eq!(Procedure::ExportOnly, match_procedure("B1", None).unwrap());
297        assert_eq!(
298            Procedure::ExportAndExitSummaryDeclaration,
299            match_procedure("B2", Some("A")).unwrap()
300        );
301    }
302
303    #[test]
304    fn procedure_not_matched_test() {
305        use MrnGeneratorError::*;
306
307        assert_eq!(
308            Err(InvalidProcedureCategoryCombination {
309                procedure_category: "B2".to_string(),
310                combination: "B".to_string()
311            }),
312            match_procedure("B2", Some("B"))
313        );
314
315        let invalid_procedure_category = "not a valid procedure 🤡";
316
317        assert_eq!(
318            Err(InvalidProcedureCategory(
319                invalid_procedure_category.to_string()
320            )),
321            match_procedure(invalid_procedure_category, None)
322        );
323        assert_eq!(
324            Err(InvalidProcedureCategoryCombination {
325                procedure_category: invalid_procedure_category.to_string(),
326                combination: "F".to_string()
327            }),
328            match_procedure(invalid_procedure_category, Some("F"))
329        );
330    }
331
332    #[test]
333    fn capitalize_test() {
334        assert_eq!("BAT", capitalize("bat"))
335    }
336
337    #[test]
338    fn replace_last_char_test() {
339        assert_eq!("bar", replace_last_char("bat", 'r'))
340    }
341
342    #[test]
343    fn check_remainder_value_test() {
344        assert_eq!(None, check_remainder_value(3, '3'));
345        assert_eq!(None, check_remainder_value(10, '0'));
346        assert_eq!(Some('3'), check_remainder_value(3, '5'));
347        assert_eq!(Some('0'), check_remainder_value(10, '9'));
348    }
349
350    #[test]
351    fn check_character_value_test() {
352        assert_eq!(3, check_character_value('3').unwrap());
353        assert_eq!(10, check_character_value('A').unwrap());
354        assert_eq!(13, check_character_value('C').unwrap());
355        assert_eq!(35, check_character_value('W').unwrap());
356        assert_eq!(
357            Err(MrnGeneratorError::NotAlphanumeric('🤡')),
358            check_character_value('🤡')
359        );
360    }
361}