Skip to main content

camt053/simple/
mt940.rs

1//! MT940 export, built on top of the bank agnostic [`crate::simple`]
2//! structs rather than directly on the camt.053 schema types.
3
4use crate::{SimpleStatement, SimpleTransaction};
5use chrono::NaiveDate;
6use rust_decimal::Decimal;
7
8impl SimpleStatement {
9    /// Renders this statement as a single MT940 message.
10    pub fn to_mt940(&self) -> String {
11        let mut stmt_940 = String::default();
12
13        stmt_940 += "{1:F01SNSBNL2AXXXX0000000000}{2:O940SNSBNL2AXXXXN}{3:}{4:\r\n";
14        stmt_940 += ":20:0000000000\r\n";
15        stmt_940 += &format!(":25:{}\r\n", self.account);
16        stmt_940 += &format!(
17            "{}\r\n",
18            balance_tag_940("60F", self.opening_date, self.opening_amount)
19        );
20
21        let mut amount = self.opening_amount;
22        for transaction in &self.transactions {
23            amount += transaction.amount;
24            stmt_940 += &format!("{}\r\n", transaction.entry_940_61());
25            stmt_940 += &format!("{}\r\n", transaction.entry_940_86());
26        }
27
28        stmt_940 += &format!("{}\r\n", balance_tag_940("62F", self.closing_date, amount));
29        stmt_940 += "-}{5:}\r\n";
30        stmt_940
31    }
32
33    /// Suggested filename for the MT940 export of this statement, based on
34    /// its opening and closing date.
35    pub fn filename_940(&self) -> String {
36        format!(
37            "{}_{}_{}.940",
38            self.account,
39            self.opening_date.format("%Y-%m-%d"),
40            self.closing_date.format("%Y-%m-%d"),
41        )
42    }
43}
44
45impl SimpleTransaction {
46    fn entry_940_61(&self) -> String {
47        let amount = if self.amount.is_sign_negative() {
48            format!("D{:.2}", -self.amount)
49        } else {
50            format!("C{:.2}", self.amount)
51        };
52        let amount = amount.replace('.', ",");
53
54        format!(
55            ":61:{}{}{}NDIV",
56            self.value_date.format("%y%m%d"),
57            self.book_date.format("%m%d"),
58            amount
59        )
60    }
61
62    fn entry_940_86(&self) -> String {
63        format!(":86:{}", self.description)
64    }
65}
66
67/// Formats a `60F`/`62F` style balance tag: `:<tag>:<C|D><yymmdd>EUR<amount>`.
68fn balance_tag_940(tag: &str, date: NaiveDate, amount: Decimal) -> String {
69    let date = date.format("%y%m%d");
70    let res = if amount.is_sign_negative() {
71        format!(":{tag}:D{date}EUR{:.2}", -amount)
72    } else {
73        format!(":{tag}:C{date}EUR{:.2}", amount)
74    };
75    res.replace('.', ",")
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::{Document, simple};
82    use rust_decimal_macros::dec;
83
84    fn test_statement() -> SimpleStatement {
85        let doc = Document::from_reader(simple::TEST_XML.as_bytes()).expect("valid test fixture");
86        SimpleStatement::from_document(&doc)
87            .expect("valid statement")
88            .remove(0)
89    }
90
91    #[test]
92    fn balance_tag_940_formats_credit_and_debit() {
93        let date = NaiveDate::from_ymd_opt(2026, 1, 23).unwrap();
94        assert_eq!(
95            balance_tag_940("60F", date, dec!(203.69)),
96            ":60F:C260123EUR203,69"
97        );
98        assert_eq!(
99            balance_tag_940("60F", date, dec!(-203.69)),
100            ":60F:D260123EUR203,69"
101        );
102    }
103
104    #[test]
105    fn entry_940_61_formats_debit_and_credit_entries() {
106        let statement = test_statement();
107        assert_eq!(
108            statement.transactions[0].entry_940_61(),
109            ":61:2603050305D100,00NDIV"
110        );
111        assert_eq!(
112            statement.transactions[1].entry_940_61(),
113            ":61:2604100410C50,00NDIV"
114        );
115    }
116
117    #[test]
118    fn entry_940_86_uses_raw_description() {
119        let statement = test_statement();
120        assert_eq!(
121            statement.transactions[0].entry_940_86(),
122            ":86:Payment for invoice 123"
123        );
124    }
125
126    #[test]
127    fn to_mt940_produces_expected_header_and_balances() {
128        let statement = test_statement();
129        let mt940 = statement.to_mt940();
130        assert!(mt940.starts_with("{1:F01SNSBNL2AXXXX0000000000}{2:O940SNSBNL2AXXXXN}{3:}{4:\r\n"));
131        assert!(mt940.contains(":25:NL00SNSB0000000000\r\n"));
132        assert!(mt940.contains(":60F:C260101EUR1000,00\r\n"));
133        // Opening 1000.00 - 100.00 (debit) + 50.00 (credit) = 950.00
134        assert!(mt940.contains(":62F:C260721EUR950,00\r\n"));
135        assert!(mt940.ends_with("-}{5:}\r\n"));
136    }
137
138    #[test]
139    fn filename_940_uses_opening_and_closing_dates() {
140        let statement = test_statement();
141        assert_eq!(
142            statement.filename_940(),
143            "NL00SNSB0000000000_2026-01-01_2026-07-21.940"
144        );
145    }
146}