1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7use use_amount::Amount;
8use use_transaction::TransactionDirection;
9
10pub mod prelude {
12 pub use crate::{
13 AccountIdentifierRecord, AccountTrailerRecord, Bai2Error, ContinuationRecord,
14 FileHeaderRecord, FileTrailerRecord, FundsTypeCode, GroupHeaderRecord, GroupTrailerRecord,
15 NormalizedTransaction, RawRecord, RecordCode, TransactionDetailRecord, TransactionTypeCode,
16 parse_line, parse_logical_records,
17 };
18}
19
20#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
22pub enum RecordCode {
23 FileHeader,
25 GroupHeader,
27 AccountIdentifier,
29 TransactionDetail,
31 AccountTrailer,
33 Continuation,
35 GroupTrailer,
37 FileTrailer,
39}
40
41impl RecordCode {
42 pub fn new(value: impl AsRef<str>) -> Result<Self, Bai2Error> {
48 match value.as_ref().trim() {
49 "01" => Ok(Self::FileHeader),
50 "02" => Ok(Self::GroupHeader),
51 "03" => Ok(Self::AccountIdentifier),
52 "16" => Ok(Self::TransactionDetail),
53 "49" => Ok(Self::AccountTrailer),
54 "88" => Ok(Self::Continuation),
55 "98" => Ok(Self::GroupTrailer),
56 "99" => Ok(Self::FileTrailer),
57 other => Err(Bai2Error::UnknownRecordCode(other.to_string())),
58 }
59 }
60
61 #[must_use]
63 pub const fn as_str(self) -> &'static str {
64 match self {
65 Self::FileHeader => "01",
66 Self::GroupHeader => "02",
67 Self::AccountIdentifier => "03",
68 Self::TransactionDetail => "16",
69 Self::AccountTrailer => "49",
70 Self::Continuation => "88",
71 Self::GroupTrailer => "98",
72 Self::FileTrailer => "99",
73 }
74 }
75}
76
77impl fmt::Display for RecordCode {
78 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79 formatter.write_str(self.as_str())
80 }
81}
82
83impl FromStr for RecordCode {
84 type Err = Bai2Error;
85
86 fn from_str(value: &str) -> Result<Self, Self::Err> {
87 Self::new(value)
88 }
89}
90
91#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct RawRecord {
94 code: RecordCode,
95 fields: Vec<String>,
96}
97
98impl RawRecord {
99 #[must_use]
101 pub const fn new(code: RecordCode, fields: Vec<String>) -> Self {
102 Self { code, fields }
103 }
104
105 #[must_use]
107 pub const fn code(&self) -> RecordCode {
108 self.code
109 }
110
111 #[must_use]
113 pub fn fields(&self) -> &[String] {
114 &self.fields
115 }
116
117 fn push_fields(&mut self, fields: Vec<String>) {
118 self.fields.extend(fields);
119 }
120}
121
122pub fn parse_line(line: &str) -> Result<RawRecord, Bai2Error> {
129 let line = line.trim();
130 if line.is_empty() {
131 return Err(Bai2Error::EmptyLine);
132 }
133
134 let Some(content) = line.strip_suffix('/') else {
135 return Err(Bai2Error::MissingTerminator);
136 };
137
138 let mut parts = content.split(',');
139 let code = parts.next().ok_or(Bai2Error::MissingRecordCode)?;
140 let code = RecordCode::new(code)?;
141 let fields = parts.map(|field| field.trim().to_string()).collect();
142 Ok(RawRecord::new(code, fields))
143}
144
145pub fn parse_logical_records(input: &str) -> Result<Vec<RawRecord>, Bai2Error> {
152 let mut records: Vec<RawRecord> = Vec::new();
153
154 for line in input.lines().filter(|line| !line.trim().is_empty()) {
155 let record = parse_line(line)?;
156 if record.code() == RecordCode::Continuation {
157 let Some(previous) = records.last_mut() else {
158 return Err(Bai2Error::OrphanContinuation);
159 };
160 previous.push_fields(record.fields);
161 } else {
162 records.push(record);
163 }
164 }
165
166 Ok(records)
167}
168
169#[derive(Clone, Debug, Eq, PartialEq)]
171pub struct FileHeaderRecord {
172 sender_id: String,
173 receiver_id: String,
174 creation_date: String,
175 creation_time: String,
176 file_id: Option<String>,
177}
178
179impl FileHeaderRecord {
180 #[must_use]
182 pub fn sender_id(&self) -> &str {
183 &self.sender_id
184 }
185
186 #[must_use]
188 pub fn receiver_id(&self) -> &str {
189 &self.receiver_id
190 }
191
192 #[must_use]
194 pub fn creation_date(&self) -> &str {
195 &self.creation_date
196 }
197
198 #[must_use]
200 pub fn creation_time(&self) -> &str {
201 &self.creation_time
202 }
203
204 #[must_use]
206 pub fn file_id(&self) -> Option<&str> {
207 self.file_id.as_deref()
208 }
209}
210
211impl TryFrom<&RawRecord> for FileHeaderRecord {
212 type Error = Bai2Error;
213
214 fn try_from(record: &RawRecord) -> Result<Self, Self::Error> {
215 ensure_code(record, RecordCode::FileHeader)?;
216 Ok(Self {
217 sender_id: required_field(record, 0, "sender_id")?.to_string(),
218 receiver_id: required_field(record, 1, "receiver_id")?.to_string(),
219 creation_date: required_field(record, 2, "creation_date")?.to_string(),
220 creation_time: required_field(record, 3, "creation_time")?.to_string(),
221 file_id: optional_field(record, 4),
222 })
223 }
224}
225
226#[derive(Clone, Debug, Eq, PartialEq)]
228pub struct GroupHeaderRecord {
229 receiver_id: String,
230 originator_id: String,
231 group_status: String,
232 as_of_date: String,
233 as_of_time: String,
234}
235
236impl TryFrom<&RawRecord> for GroupHeaderRecord {
237 type Error = Bai2Error;
238
239 fn try_from(record: &RawRecord) -> Result<Self, Self::Error> {
240 ensure_code(record, RecordCode::GroupHeader)?;
241 Ok(Self {
242 receiver_id: required_field(record, 0, "receiver_id")?.to_string(),
243 originator_id: required_field(record, 1, "originator_id")?.to_string(),
244 group_status: required_field(record, 2, "group_status")?.to_string(),
245 as_of_date: required_field(record, 3, "as_of_date")?.to_string(),
246 as_of_time: required_field(record, 4, "as_of_time")?.to_string(),
247 })
248 }
249}
250
251impl GroupHeaderRecord {
252 #[must_use]
254 pub fn receiver_id(&self) -> &str {
255 &self.receiver_id
256 }
257
258 #[must_use]
260 pub fn originator_id(&self) -> &str {
261 &self.originator_id
262 }
263
264 #[must_use]
266 pub fn group_status(&self) -> &str {
267 &self.group_status
268 }
269
270 #[must_use]
272 pub fn as_of_date(&self) -> &str {
273 &self.as_of_date
274 }
275
276 #[must_use]
278 pub fn as_of_time(&self) -> &str {
279 &self.as_of_time
280 }
281}
282
283#[derive(Clone, Debug, Eq, PartialEq)]
285pub struct AccountIdentifierRecord {
286 customer_account_number: String,
287 currency_code: Option<String>,
288 summary_fields: Vec<String>,
289}
290
291impl TryFrom<&RawRecord> for AccountIdentifierRecord {
292 type Error = Bai2Error;
293
294 fn try_from(record: &RawRecord) -> Result<Self, Self::Error> {
295 ensure_code(record, RecordCode::AccountIdentifier)?;
296 Ok(Self {
297 customer_account_number: required_field(record, 0, "customer_account_number")?
298 .to_string(),
299 currency_code: optional_field(record, 1),
300 summary_fields: record.fields().get(2..).unwrap_or_default().to_vec(),
301 })
302 }
303}
304
305impl AccountIdentifierRecord {
306 #[must_use]
308 pub fn customer_account_number(&self) -> &str {
309 &self.customer_account_number
310 }
311
312 #[must_use]
314 pub fn currency_code(&self) -> Option<&str> {
315 self.currency_code.as_deref()
316 }
317
318 #[must_use]
320 pub fn summary_fields(&self) -> &[String] {
321 &self.summary_fields
322 }
323}
324
325#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
327pub struct TransactionTypeCode(String);
328
329impl TransactionTypeCode {
330 pub fn new(value: impl AsRef<str>) -> Result<Self, Bai2Error> {
336 let value = value.as_ref().trim();
337 if value.is_empty() {
338 return Err(Bai2Error::MissingField {
339 record: RecordCode::TransactionDetail,
340 field: "transaction_type_code",
341 });
342 }
343 Ok(Self(value.to_string()))
344 }
345
346 #[must_use]
348 pub fn as_str(&self) -> &str {
349 &self.0
350 }
351
352 fn direction(&self) -> Result<TransactionDirection, Bai2Error> {
353 match self.0.as_bytes().first().copied() {
354 Some(b'1' | b'2' | b'3') => Ok(TransactionDirection::Inflow),
355 Some(b'4' | b'5' | b'6') => Ok(TransactionDirection::Outflow),
356 _ => Err(Bai2Error::UnknownTransactionDirection(self.0.clone())),
357 }
358 }
359}
360
361#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
363pub struct FundsTypeCode(String);
364
365impl FundsTypeCode {
366 pub fn new(value: impl AsRef<str>) -> Result<Self, Bai2Error> {
372 let value = value.as_ref().trim();
373 if value.is_empty() {
374 return Err(Bai2Error::MissingField {
375 record: RecordCode::TransactionDetail,
376 field: "funds_type_code",
377 });
378 }
379 Ok(Self(value.to_string()))
380 }
381
382 #[must_use]
384 pub fn as_str(&self) -> &str {
385 &self.0
386 }
387}
388
389#[derive(Clone, Debug, Eq, PartialEq)]
391pub struct TransactionDetailRecord {
392 transaction_type: TransactionTypeCode,
393 amount: Amount,
394 funds_type: Option<FundsTypeCode>,
395 bank_reference: Option<String>,
396 customer_reference: Option<String>,
397 text: Option<String>,
398}
399
400impl TryFrom<&RawRecord> for TransactionDetailRecord {
401 type Error = Bai2Error;
402
403 fn try_from(record: &RawRecord) -> Result<Self, Self::Error> {
404 ensure_code(record, RecordCode::TransactionDetail)?;
405 let transaction_type =
406 TransactionTypeCode::new(required_field(record, 0, "transaction_type_code")?)?;
407 let amount = parse_amount(required_field(record, 1, "amount")?)?;
408 let funds_type = match optional_field(record, 2) {
409 Some(value) => Some(FundsTypeCode::new(value)?),
410 None => None,
411 };
412 let text = record.fields().get(5..).and_then(|fields| {
413 if fields.is_empty() {
414 None
415 } else {
416 Some(fields.join(","))
417 }
418 });
419
420 Ok(Self {
421 transaction_type,
422 amount,
423 funds_type,
424 bank_reference: optional_field(record, 3),
425 customer_reference: optional_field(record, 4),
426 text,
427 })
428 }
429}
430
431impl TransactionDetailRecord {
432 #[must_use]
434 pub const fn transaction_type(&self) -> &TransactionTypeCode {
435 &self.transaction_type
436 }
437
438 #[must_use]
440 pub const fn amount(&self) -> Amount {
441 self.amount
442 }
443
444 #[must_use]
446 pub const fn funds_type(&self) -> Option<&FundsTypeCode> {
447 self.funds_type.as_ref()
448 }
449
450 #[must_use]
452 pub fn bank_reference(&self) -> Option<&str> {
453 self.bank_reference.as_deref()
454 }
455
456 #[must_use]
458 pub fn customer_reference(&self) -> Option<&str> {
459 self.customer_reference.as_deref()
460 }
461
462 #[must_use]
464 pub fn text(&self) -> Option<&str> {
465 self.text.as_deref()
466 }
467}
468
469#[derive(Clone, Debug, Eq, PartialEq)]
471pub struct ContinuationRecord {
472 fields: Vec<String>,
473}
474
475impl TryFrom<&RawRecord> for ContinuationRecord {
476 type Error = Bai2Error;
477
478 fn try_from(record: &RawRecord) -> Result<Self, Self::Error> {
479 ensure_code(record, RecordCode::Continuation)?;
480 Ok(Self {
481 fields: record.fields().to_vec(),
482 })
483 }
484}
485
486impl ContinuationRecord {
487 #[must_use]
489 pub fn fields(&self) -> &[String] {
490 &self.fields
491 }
492}
493
494#[derive(Clone, Debug, Eq, PartialEq)]
496pub struct AccountTrailerRecord {
497 account_control_total: Option<i128>,
498 record_count: Option<usize>,
499}
500
501impl TryFrom<&RawRecord> for AccountTrailerRecord {
502 type Error = Bai2Error;
503
504 fn try_from(record: &RawRecord) -> Result<Self, Self::Error> {
505 ensure_code(record, RecordCode::AccountTrailer)?;
506 Ok(Self {
507 account_control_total: optional_i128(record, 0)?,
508 record_count: optional_usize(record, 1)?,
509 })
510 }
511}
512
513impl AccountTrailerRecord {
514 #[must_use]
516 pub const fn account_control_total(&self) -> Option<i128> {
517 self.account_control_total
518 }
519
520 #[must_use]
522 pub const fn record_count(&self) -> Option<usize> {
523 self.record_count
524 }
525}
526
527#[derive(Clone, Debug, Eq, PartialEq)]
529pub struct GroupTrailerRecord {
530 group_control_total: Option<i128>,
531 account_count: Option<usize>,
532 record_count: Option<usize>,
533}
534
535impl TryFrom<&RawRecord> for GroupTrailerRecord {
536 type Error = Bai2Error;
537
538 fn try_from(record: &RawRecord) -> Result<Self, Self::Error> {
539 ensure_code(record, RecordCode::GroupTrailer)?;
540 Ok(Self {
541 group_control_total: optional_i128(record, 0)?,
542 account_count: optional_usize(record, 1)?,
543 record_count: optional_usize(record, 2)?,
544 })
545 }
546}
547
548impl GroupTrailerRecord {
549 #[must_use]
551 pub const fn group_control_total(&self) -> Option<i128> {
552 self.group_control_total
553 }
554
555 #[must_use]
557 pub const fn account_count(&self) -> Option<usize> {
558 self.account_count
559 }
560
561 #[must_use]
563 pub const fn record_count(&self) -> Option<usize> {
564 self.record_count
565 }
566}
567
568#[derive(Clone, Debug, Eq, PartialEq)]
570pub struct FileTrailerRecord {
571 file_control_total: Option<i128>,
572 group_count: Option<usize>,
573 record_count: Option<usize>,
574}
575
576impl TryFrom<&RawRecord> for FileTrailerRecord {
577 type Error = Bai2Error;
578
579 fn try_from(record: &RawRecord) -> Result<Self, Self::Error> {
580 ensure_code(record, RecordCode::FileTrailer)?;
581 Ok(Self {
582 file_control_total: optional_i128(record, 0)?,
583 group_count: optional_usize(record, 1)?,
584 record_count: optional_usize(record, 2)?,
585 })
586 }
587}
588
589impl FileTrailerRecord {
590 #[must_use]
592 pub const fn file_control_total(&self) -> Option<i128> {
593 self.file_control_total
594 }
595
596 #[must_use]
598 pub const fn group_count(&self) -> Option<usize> {
599 self.group_count
600 }
601
602 #[must_use]
604 pub const fn record_count(&self) -> Option<usize> {
605 self.record_count
606 }
607
608 pub const fn validate_record_count(&self, actual: usize) -> Result<(), Bai2Error> {
614 match self.record_count {
615 Some(expected) if expected != actual => Err(Bai2Error::InvalidCount),
616 _ => Ok(()),
617 }
618 }
619}
620
621#[derive(Clone, Debug, Eq, PartialEq)]
623pub struct NormalizedTransaction {
624 transaction_type: TransactionTypeCode,
625 amount: Amount,
626 direction: TransactionDirection,
627 bank_reference: Option<String>,
628 customer_reference: Option<String>,
629 text: Option<String>,
630}
631
632impl NormalizedTransaction {
633 pub fn from_detail(detail: &TransactionDetailRecord) -> Result<Self, Bai2Error> {
640 Ok(Self {
641 transaction_type: detail.transaction_type.clone(),
642 amount: detail.amount,
643 direction: detail.transaction_type.direction()?,
644 bank_reference: detail.bank_reference.clone(),
645 customer_reference: detail.customer_reference.clone(),
646 text: detail.text.clone(),
647 })
648 }
649
650 #[must_use]
652 pub const fn transaction_type(&self) -> &TransactionTypeCode {
653 &self.transaction_type
654 }
655
656 #[must_use]
658 pub const fn amount(&self) -> Amount {
659 self.amount
660 }
661
662 #[must_use]
664 pub const fn direction(&self) -> TransactionDirection {
665 self.direction
666 }
667
668 #[must_use]
670 pub fn bank_reference(&self) -> Option<&str> {
671 self.bank_reference.as_deref()
672 }
673
674 #[must_use]
676 pub fn customer_reference(&self) -> Option<&str> {
677 self.customer_reference.as_deref()
678 }
679
680 #[must_use]
682 pub fn text(&self) -> Option<&str> {
683 self.text.as_deref()
684 }
685}
686
687#[derive(Clone, Debug, Eq, PartialEq)]
689pub enum Bai2Error {
690 EmptyLine,
692 MissingTerminator,
694 MissingRecordCode,
696 UnknownRecordCode(String),
698 UnexpectedRecordCode {
700 expected: RecordCode,
702 actual: RecordCode,
704 },
705 MissingField {
707 record: RecordCode,
709 field: &'static str,
711 },
712 InvalidAmount,
714 InvalidCount,
716 OrphanContinuation,
718 UnknownTransactionDirection(String),
720}
721
722impl fmt::Display for Bai2Error {
723 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
724 match self {
725 Self::EmptyLine => formatter.write_str("BAI2 line cannot be empty"),
726 Self::MissingTerminator => formatter.write_str("BAI2 line must end with /"),
727 Self::MissingRecordCode => formatter.write_str("BAI2 record code is missing"),
728 Self::UnknownRecordCode(code) => {
729 write!(formatter, "unsupported BAI2 record code: {code}")
730 },
731 Self::UnexpectedRecordCode { expected, actual } => write!(
732 formatter,
733 "expected BAI2 record code {expected}, got {actual}"
734 ),
735 Self::MissingField { record, field } => {
736 write!(formatter, "BAI2 record {record} missing field {field}")
737 },
738 Self::InvalidAmount => formatter.write_str("BAI2 amount field is invalid"),
739 Self::InvalidCount => formatter.write_str("BAI2 count field is invalid"),
740 Self::OrphanContinuation => {
741 formatter.write_str("BAI2 continuation record has no parent")
742 },
743 Self::UnknownTransactionDirection(code) => write!(
744 formatter,
745 "BAI2 transaction type code {code} has unknown direction"
746 ),
747 }
748 }
749}
750
751impl Error for Bai2Error {}
752
753fn ensure_code(record: &RawRecord, expected: RecordCode) -> Result<(), Bai2Error> {
754 if record.code() == expected {
755 Ok(())
756 } else {
757 Err(Bai2Error::UnexpectedRecordCode {
758 expected,
759 actual: record.code(),
760 })
761 }
762}
763
764fn required_field<'a>(
765 record: &'a RawRecord,
766 index: usize,
767 field: &'static str,
768) -> Result<&'a str, Bai2Error> {
769 let value = record
770 .fields()
771 .get(index)
772 .map(String::as_str)
773 .unwrap_or_default()
774 .trim();
775 if value.is_empty() {
776 Err(Bai2Error::MissingField {
777 record: record.code(),
778 field,
779 })
780 } else {
781 Ok(value)
782 }
783}
784
785fn optional_field(record: &RawRecord, index: usize) -> Option<String> {
786 record.fields().get(index).and_then(|value| {
787 let trimmed = value.trim();
788 if trimmed.is_empty() {
789 None
790 } else {
791 Some(trimmed.to_string())
792 }
793 })
794}
795
796fn optional_i128(record: &RawRecord, index: usize) -> Result<Option<i128>, Bai2Error> {
797 optional_field(record, index)
798 .map(|value| value.parse::<i128>().map_err(|_| Bai2Error::InvalidAmount))
799 .transpose()
800}
801
802fn optional_usize(record: &RawRecord, index: usize) -> Result<Option<usize>, Bai2Error> {
803 optional_field(record, index)
804 .map(|value| value.parse::<usize>().map_err(|_| Bai2Error::InvalidCount))
805 .transpose()
806}
807
808fn parse_amount(value: &str) -> Result<Amount, Bai2Error> {
809 let minor_units = value
810 .parse::<i128>()
811 .map_err(|_| Bai2Error::InvalidAmount)?;
812 Amount::from_minor_units(minor_units, 2).map_err(|_| Bai2Error::InvalidAmount)
813}
814
815#[cfg(test)]
816mod tests {
817 use use_transaction::TransactionDirection;
818
819 use super::{
820 Bai2Error, FileTrailerRecord, NormalizedTransaction, RawRecord, RecordCode,
821 TransactionDetailRecord, parse_line, parse_logical_records,
822 };
823
824 #[test]
825 fn parses_transaction_detail_line() -> Result<(), Box<dyn std::error::Error>> {
826 let record = parse_line("16,475,12345,Z,bank-ref,customer-ref,payment/")?;
827 let detail = TransactionDetailRecord::try_from(&record)?;
828 let normalized = NormalizedTransaction::from_detail(&detail)?;
829
830 assert_eq!(record.code(), RecordCode::TransactionDetail);
831 assert_eq!(detail.amount().minor_units(), 12_345);
832 assert_eq!(detail.bank_reference(), Some("bank-ref"));
833 assert_eq!(normalized.direction(), TransactionDirection::Outflow);
834 Ok(())
835 }
836
837 #[test]
838 fn folds_continuation_records() -> Result<(), Box<dyn std::error::Error>> {
839 let records = parse_logical_records(
840 "16,475,12345,Z,bank-ref,customer-ref,first/\n88,second,third/\n",
841 )?;
842 let detail = TransactionDetailRecord::try_from(&records[0])?;
843
844 assert_eq!(records.len(), 1);
845 assert_eq!(detail.text(), Some("first,second,third"));
846 Ok(())
847 }
848
849 #[test]
850 fn rejects_invalid_record_code_and_orphan_continuation() {
851 assert_eq!(
852 parse_line("77,abc/"),
853 Err(Bai2Error::UnknownRecordCode("77".to_string()))
854 );
855 assert_eq!(
856 parse_logical_records("88,orphan/"),
857 Err(Bai2Error::OrphanContinuation)
858 );
859 }
860
861 #[test]
862 fn validates_file_trailer_count() -> Result<(), Box<dyn std::error::Error>> {
863 let trailer_record = RawRecord::new(
864 RecordCode::FileTrailer,
865 vec!["0".to_string(), "1".to_string(), "3".to_string()],
866 );
867 let trailer = FileTrailerRecord::try_from(&trailer_record)?;
868
869 assert_eq!(trailer.validate_record_count(3), Ok(()));
870 assert_eq!(
871 trailer.validate_record_count(2),
872 Err(Bai2Error::InvalidCount)
873 );
874 Ok(())
875 }
876}