Skip to main content

use_bai2/
lib.rs

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
10/// Common BAI2 primitives.
11pub 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/// Supported BAI2 record codes.
21#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
22pub enum RecordCode {
23    /// `01` file header.
24    FileHeader,
25    /// `02` group header.
26    GroupHeader,
27    /// `03` account identifier.
28    AccountIdentifier,
29    /// `16` transaction detail.
30    TransactionDetail,
31    /// `49` account trailer.
32    AccountTrailer,
33    /// `88` continuation.
34    Continuation,
35    /// `98` group trailer.
36    GroupTrailer,
37    /// `99` file trailer.
38    FileTrailer,
39}
40
41impl RecordCode {
42    /// Parses a BAI2 record code.
43    ///
44    /// # Errors
45    ///
46    /// Returns [`Bai2Error::UnknownRecordCode`] when the code is not supported.
47    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    /// Returns the two-character BAI2 record code.
62    #[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/// A parsed BAI2 record with raw fields preserved.
92#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct RawRecord {
94    code: RecordCode,
95    fields: Vec<String>,
96}
97
98impl RawRecord {
99    /// Creates a raw record.
100    #[must_use]
101    pub const fn new(code: RecordCode, fields: Vec<String>) -> Self {
102        Self { code, fields }
103    }
104
105    /// Returns the record code.
106    #[must_use]
107    pub const fn code(&self) -> RecordCode {
108        self.code
109    }
110
111    /// Returns raw fields after the record code.
112    #[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
122/// Parses a single slash-terminated BAI2 line.
123///
124/// # Errors
125///
126/// Returns [`Bai2Error`] when the line is empty, lacks a slash terminator, or uses an unsupported
127/// record code.
128pub 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
145/// Parses BAI2 lines and folds `88` continuation records into the previous logical record.
146///
147/// # Errors
148///
149/// Returns [`Bai2Error::OrphanContinuation`] when a continuation appears before any logical record,
150/// plus any parsing error returned by [`parse_line`].
151pub 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/// BAI2 file header record.
170#[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    /// Returns the sender identifier.
181    #[must_use]
182    pub fn sender_id(&self) -> &str {
183        &self.sender_id
184    }
185
186    /// Returns the receiver identifier.
187    #[must_use]
188    pub fn receiver_id(&self) -> &str {
189        &self.receiver_id
190    }
191
192    /// Returns the creation date field.
193    #[must_use]
194    pub fn creation_date(&self) -> &str {
195        &self.creation_date
196    }
197
198    /// Returns the creation time field.
199    #[must_use]
200    pub fn creation_time(&self) -> &str {
201        &self.creation_time
202    }
203
204    /// Returns the optional file identifier.
205    #[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/// BAI2 group header record.
227#[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    /// Returns the receiver identifier.
253    #[must_use]
254    pub fn receiver_id(&self) -> &str {
255        &self.receiver_id
256    }
257
258    /// Returns the originator identifier.
259    #[must_use]
260    pub fn originator_id(&self) -> &str {
261        &self.originator_id
262    }
263
264    /// Returns the group status field.
265    #[must_use]
266    pub fn group_status(&self) -> &str {
267        &self.group_status
268    }
269
270    /// Returns the as-of date field.
271    #[must_use]
272    pub fn as_of_date(&self) -> &str {
273        &self.as_of_date
274    }
275
276    /// Returns the as-of time field.
277    #[must_use]
278    pub fn as_of_time(&self) -> &str {
279        &self.as_of_time
280    }
281}
282
283/// BAI2 account identifier record.
284#[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    /// Returns the customer account number.
307    #[must_use]
308    pub fn customer_account_number(&self) -> &str {
309        &self.customer_account_number
310    }
311
312    /// Returns the optional currency code field.
313    #[must_use]
314    pub fn currency_code(&self) -> Option<&str> {
315        self.currency_code.as_deref()
316    }
317
318    /// Returns preserved summary fields.
319    #[must_use]
320    pub fn summary_fields(&self) -> &[String] {
321        &self.summary_fields
322    }
323}
324
325/// A raw BAI2 transaction type code.
326#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
327pub struct TransactionTypeCode(String);
328
329impl TransactionTypeCode {
330    /// Creates a raw transaction type code.
331    ///
332    /// # Errors
333    ///
334    /// Returns [`Bai2Error::MissingField`] when the code is empty.
335    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    /// Returns the raw transaction type code.
347    #[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/// A raw BAI2 funds type code.
362#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
363pub struct FundsTypeCode(String);
364
365impl FundsTypeCode {
366    /// Creates a raw funds type code.
367    ///
368    /// # Errors
369    ///
370    /// Returns [`Bai2Error::MissingField`] when the code is empty.
371    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    /// Returns the raw funds type code.
383    #[must_use]
384    pub fn as_str(&self) -> &str {
385        &self.0
386    }
387}
388
389/// BAI2 transaction detail record.
390#[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    /// Returns the transaction type code.
433    #[must_use]
434    pub const fn transaction_type(&self) -> &TransactionTypeCode {
435        &self.transaction_type
436    }
437
438    /// Returns the transaction amount, interpreted as minor units with scale 2.
439    #[must_use]
440    pub const fn amount(&self) -> Amount {
441        self.amount
442    }
443
444    /// Returns the optional funds type code.
445    #[must_use]
446    pub const fn funds_type(&self) -> Option<&FundsTypeCode> {
447        self.funds_type.as_ref()
448    }
449
450    /// Returns the optional bank reference.
451    #[must_use]
452    pub fn bank_reference(&self) -> Option<&str> {
453        self.bank_reference.as_deref()
454    }
455
456    /// Returns the optional customer reference.
457    #[must_use]
458    pub fn customer_reference(&self) -> Option<&str> {
459        self.customer_reference.as_deref()
460    }
461
462    /// Returns the optional transaction text.
463    #[must_use]
464    pub fn text(&self) -> Option<&str> {
465        self.text.as_deref()
466    }
467}
468
469/// BAI2 continuation record.
470#[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    /// Returns continuation fields.
488    #[must_use]
489    pub fn fields(&self) -> &[String] {
490        &self.fields
491    }
492}
493
494/// BAI2 account trailer record.
495#[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    /// Returns the optional account control total.
515    #[must_use]
516    pub const fn account_control_total(&self) -> Option<i128> {
517        self.account_control_total
518    }
519
520    /// Returns the optional record count.
521    #[must_use]
522    pub const fn record_count(&self) -> Option<usize> {
523        self.record_count
524    }
525}
526
527/// BAI2 group trailer record.
528#[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    /// Returns the optional group control total.
550    #[must_use]
551    pub const fn group_control_total(&self) -> Option<i128> {
552        self.group_control_total
553    }
554
555    /// Returns the optional account count.
556    #[must_use]
557    pub const fn account_count(&self) -> Option<usize> {
558        self.account_count
559    }
560
561    /// Returns the optional record count.
562    #[must_use]
563    pub const fn record_count(&self) -> Option<usize> {
564        self.record_count
565    }
566}
567
568/// BAI2 file trailer record.
569#[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    /// Returns the optional file control total.
591    #[must_use]
592    pub const fn file_control_total(&self) -> Option<i128> {
593        self.file_control_total
594    }
595
596    /// Returns the optional group count.
597    #[must_use]
598    pub const fn group_count(&self) -> Option<usize> {
599        self.group_count
600    }
601
602    /// Returns the optional record count.
603    #[must_use]
604    pub const fn record_count(&self) -> Option<usize> {
605        self.record_count
606    }
607
608    /// Validates a parsed logical record count against the trailer record count when present.
609    ///
610    /// # Errors
611    ///
612    /// Returns [`Bai2Error::InvalidCount`] when the expected count is present and differs.
613    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/// A normalized transaction detail value.
622#[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    /// Normalizes a BAI2 transaction detail record.
634    ///
635    /// # Errors
636    ///
637    /// Returns [`Bai2Error::UnknownTransactionDirection`] when the raw type code cannot be mapped
638    /// into a conservative inflow or outflow direction.
639    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    /// Returns the raw transaction type code.
651    #[must_use]
652    pub const fn transaction_type(&self) -> &TransactionTypeCode {
653        &self.transaction_type
654    }
655
656    /// Returns the normalized amount.
657    #[must_use]
658    pub const fn amount(&self) -> Amount {
659        self.amount
660    }
661
662    /// Returns the normalized direction.
663    #[must_use]
664    pub const fn direction(&self) -> TransactionDirection {
665        self.direction
666    }
667
668    /// Returns the optional bank reference.
669    #[must_use]
670    pub fn bank_reference(&self) -> Option<&str> {
671        self.bank_reference.as_deref()
672    }
673
674    /// Returns the optional customer reference.
675    #[must_use]
676    pub fn customer_reference(&self) -> Option<&str> {
677        self.customer_reference.as_deref()
678    }
679
680    /// Returns the optional transaction text.
681    #[must_use]
682    pub fn text(&self) -> Option<&str> {
683        self.text.as_deref()
684    }
685}
686
687/// Errors returned by BAI2 parsing and validation.
688#[derive(Clone, Debug, Eq, PartialEq)]
689pub enum Bai2Error {
690    /// The input line was empty.
691    EmptyLine,
692    /// A BAI2 line did not end with `/`.
693    MissingTerminator,
694    /// A record code was missing.
695    MissingRecordCode,
696    /// The record code is unsupported.
697    UnknownRecordCode(String),
698    /// A record had an unexpected code.
699    UnexpectedRecordCode {
700        /// Expected record code.
701        expected: RecordCode,
702        /// Actual record code.
703        actual: RecordCode,
704    },
705    /// A required field was missing.
706    MissingField {
707        /// Record code.
708        record: RecordCode,
709        /// Field name.
710        field: &'static str,
711    },
712    /// An amount field was invalid.
713    InvalidAmount,
714    /// A count field was invalid or unexpected.
715    InvalidCount,
716    /// A continuation record appeared before a logical record.
717    OrphanContinuation,
718    /// A transaction type code could not be mapped into a direction.
719    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}