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
20pub 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 mrn.replace_range(16..17, &proctgr_char);
63 }
64
65 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
75pub 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 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#[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
123pub 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
144pub 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
198fn capitalize(s: &str) -> String {
200 s.chars().map(|c| c.to_ascii_uppercase()).collect()
201}
202
203fn 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
211pub 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
220pub 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}