Skip to main content

bank_barcode/
lib.rs

1//! Generate Finnish virtual bank barcodes (*pankkiviivakoodi*).
2//!
3//! This crate produces the digit string for both version 4 and version 5 of the
4//! Finnish virtual bank barcode as defined in the
5//! [Finanssiala specification](https://www.finanssiala.fi/wp-content/uploads/2021/03/Pankkiviivakoodi-opas.pdf).
6//!
7//! Build a [`Barcode`] through [`BarcodeBuilder`] and format it via its
8//! [`Display`](std::fmt::Display) implementation.
9
10use std::num::ParseIntError;
11
12use either::Either;
13use iban::Iban;
14use time::{Date, Month};
15
16#[cfg(test)]
17mod tests;
18
19/// A representation on different versions of the bank barcode
20#[non_exhaustive]
21#[derive(Debug, Clone, Copy, Default)]
22pub enum BarcodeVersion {
23    /// The version 4 of the bank barcode
24    ///
25    /// Only difference to `BarcodeVersion::V5` is that the reference number is max 20 digits long
26    V4,
27    /// The version 5 of the bank barcode
28    ///
29    /// The reference number is max 23 digits long
30    #[default]
31    V5,
32}
33
34/// This struct represents a [bank barcode](https://www.finanssiala.fi/wp-content/uploads/2021/03/Pankkiviivakoodi-opas.pdf)(pankkiviivakoodi).
35/// It stores information about the IBAN, the sum (max 999999.99€),
36/// a reference number (20 digits for V4, 23 digits for V5) and the due date.
37///
38/// Bank barcodes may only be printed for FI IBAN numbers.
39///
40/// For information about constructing it, see `BarcodeBuilder`
41///
42/// # Usage
43///
44/// ```rust
45/// let barcode = bank_barcode::Barcode::builder()
46///     .account_number("FI73 3131 3001 0000 58")
47///     .build()
48///     .unwrap();
49///
50/// assert_eq!("573313130010000580000000000000000000000000000000000000", barcode.to_string());
51/// ```
52#[derive(Debug, Clone)]
53pub struct Barcode {
54    version: BarcodeVersion,
55    account_number: iban::Iban,
56    euros: u32,
57    cents: u8,
58    reference: String,
59    due_date: Option<Date>,
60}
61
62/// This struct is used to construct a `Barcode`
63///
64/// # Usage
65/// ```rust
66/// let barcode = bank_barcode::Barcode::builder()
67///     .version(bank_barcode::BarcodeVersion::V4)
68///     .account_number("FI16 8000 1400 0502 67")
69///     .reference(12345)
70///     .calendar_due_date(2025, 2, 26)
71///     .euros(123)
72///     .cents(45)
73///     .build()
74///     .expect("Builder failed");
75/// ```
76#[derive(Debug, Default)]
77pub struct BarcodeBuilder {
78    version: BarcodeVersion,
79    account_number: Option<Either<Iban, String>>,
80    euros: u32,
81    cents: u8,
82    reference: Option<String>,
83    due_date: Option<Either<Date, (i32, u8, u8)>>,
84}
85
86/// Errors that may occur while building a [`Barcode`].
87#[derive(Debug, Clone, thiserror::Error)]
88pub enum BuilderError {
89    /// No account number was supplied to the builder.
90    #[error("No account number specified")]
91    NoAccount,
92    /// The provided account number string could not be parsed as an IBAN.
93    #[error("Failed to parse account number {0}")]
94    InvalidAccount(#[from] iban::ParseError),
95    /// The IBAN was valid but its country code is not `FI`.
96    #[error(
97        "A non FI IBAN provided. Bank barcode may only be printed for IBAN accounts starting with FI"
98    )]
99    AccountNotFinnish,
100    /// The total sum exceeds the maximum the barcode format can encode (999 999,99 €).
101    #[error("The total sum is too large (over 999 999,99)")]
102    SumTooLarge,
103    /// The cents value was outside the valid range `0..=99`.
104    #[error("The amount of cents is invalid (not between 0..=99)")]
105    InvalidCents,
106    /// The reference number is longer than the version allows
107    /// (20 digits for V4, 23 digits for V5).
108    #[error(
109        "The reference number is too large (the limit is 20 digits for V4 and 23 digits for V5)"
110    )]
111    ReferenceTooLarge,
112    /// The reference number could not be parsed as digits.
113    #[error("Invalid reference")]
114    InvalidReference(#[from] ParseIntError),
115    /// A V5 reference number does not start with the required `RF` prefix.
116    #[error("Malformed reference: The reference for V5 has to start with 'RF'")]
117    MalformedReference,
118    /// The supplied calendar date components do not form a valid date.
119    #[error("Invalid date provided {0}")]
120    InvalidDate(#[from] time::error::ComponentRange),
121}
122
123impl BarcodeBuilder {
124    /// Completes the builder and returns the [`Barcode`].
125    ///
126    /// # Errors
127    ///
128    /// Returns a [`BuilderError`] if any field is missing or invalid:
129    /// no account number, an unparseable or non-Finnish IBAN, an out-of-range
130    /// sum or cents, an oversized or malformed reference number, or an
131    /// invalid due date.
132    pub fn build(self) -> Result<Barcode, BuilderError> {
133        let account_number = match self.account_number {
134            Some(Either::Left(iban)) => iban,
135            Some(Either::Right(number)) => number.parse()?,
136            None => return Err(BuilderError::NoAccount),
137        };
138
139        if account_number.country_code() != "FI" {
140            return Err(BuilderError::AccountNotFinnish);
141        }
142
143        if self.cents >= 100 {
144            return Err(BuilderError::InvalidCents);
145        }
146
147        if self.euros >= 999_999 {
148            return Err(BuilderError::SumTooLarge);
149        }
150
151        let reference = match self.version {
152            BarcodeVersion::V4 => {
153                if let Some(rn) = self.reference.as_ref() {
154                    let _: u128 = rn.parse()?;
155                }
156
157                let reference = self.reference.unwrap_or("0".into());
158                if reference.len() > 20 {
159                    Err(BuilderError::ReferenceTooLarge)
160                } else {
161                    Ok(reference)
162                }
163            }
164            BarcodeVersion::V5 => {
165                let reference_rf = self.reference.unwrap_or("RF00".into());
166                if !reference_rf.starts_with("RF") {
167                    return Err(BuilderError::MalformedReference);
168                }
169                let _: u128 = reference_rf[2..].parse()?;
170                let reference = reference_rf[2..].to_string();
171
172                if reference.len() > 23 {
173                    Err(BuilderError::ReferenceTooLarge)
174                } else {
175                    Ok(reference)
176                }
177            }
178        }?;
179
180        let due_date = match self.due_date {
181            Some(Either::Left(date)) => Some(date),
182            Some(Either::Right((year, month, day))) => Some(Date::from_calendar_date(
183                year,
184                Month::try_from(month)?,
185                day,
186            )?),
187            None => None,
188        };
189
190        Ok(Barcode {
191            version: self.version,
192            account_number,
193            euros: self.euros,
194            cents: self.cents,
195            reference,
196            due_date,
197        })
198    }
199
200    /// Construct a `BarcodeBuilder` with `BarcodeVersion::V4`
201    #[must_use]
202    pub fn v4() -> Self {
203        Self::default().version(BarcodeVersion::V4)
204    }
205
206    /// Construct a `BarcodeBuilder` with `BarcodeVersion::V5`
207    ///
208    /// NOTE: This is also the default value for the `version`
209    #[must_use]
210    pub fn v5() -> Self {
211        Self::default().version(BarcodeVersion::V5)
212    }
213
214    /// Set the `BarcodeVersion` of the barcode. Defaults to `BarcodeVersion::V5`.
215    #[must_use]
216    pub fn version(self, version: BarcodeVersion) -> Self {
217        Self { version, ..self }
218    }
219
220    /// Specify the account number. The account number is the only *mandatory* field.
221    #[must_use]
222    #[allow(clippy::needless_pass_by_value)] // public API: keep `impl ToString` to accept any displayable value
223    pub fn account_number(self, account: impl ToString) -> Self {
224        Self {
225            account_number: Some(Either::Right(account.to_string())),
226            ..self
227        }
228    }
229
230    /// Specify the account number as an `iban::Iban` value. The account number is the only *mandatory* field.
231    #[must_use]
232    pub fn account_number_iban(self, account: iban::Iban) -> Self {
233        Self {
234            account_number: Some(Either::Left(account)),
235            ..self
236        }
237    }
238
239    /// Specify the amount of euros, default values is 0
240    #[must_use]
241    pub fn euros(self, euros: u32) -> Self {
242        Self { euros, ..self }
243    }
244
245    /// Specify the amount of cents, default value is 0.
246    /// For `BarcodeBuilder::build` to succeed, value must bet between 0 and 99.
247    ///
248    /// *NOTE*: if you want to specify the total amount of cents, use `BarcodeBuilder::sum`
249    /// instead.
250    #[must_use]
251    pub fn cents(self, cents: u8) -> Self {
252        Self { cents, ..self }
253    }
254
255    /// Specify the total amount of cents in the sum. The default value is 0.
256    #[must_use]
257    pub fn sum(self, sum: u32) -> Self {
258        Self {
259            euros: sum / 100,
260            cents: (sum % 100) as u8,
261            ..self
262        }
263    }
264
265    /// Specify the reference number. The default value is 0.
266    /// The reference number is a maximum of 20 digits for `BarcodeVersion::V4` and 23 digits for
267    /// `BarcodeVersion::V5`. The `BarcodeVersion::V5` reference number starts with the string "RF"
268    #[must_use]
269    #[allow(clippy::needless_pass_by_value)] // public API: keep `impl ToString` to accept any displayable value
270    pub fn reference(self, reference: impl ToString) -> Self {
271        Self {
272            reference: Some(reference.to_string()),
273            ..self
274        }
275    }
276
277    /// Specify the due date as a `time::date`. Default value: no due date.
278    #[must_use]
279    pub fn due_date(self, due_date: Date) -> Self {
280        Self {
281            due_date: Some(Either::Left(due_date)),
282            ..self
283        }
284    }
285
286    /// Specify the due date as year, month and day. Default value: no due date.
287    /// If invalid date is specified, the call to `BarcodeBuilder::build` will fail.
288    #[must_use]
289    pub fn calendar_due_date(self, year: i32, month: u8, day: u8) -> Self {
290        Self {
291            due_date: Some(Either::Right((year, month, day))),
292            ..self
293        }
294    }
295}
296
297impl Barcode {
298    /// Construct a builder, for more information see `BarcodeBuilder`.
299    #[must_use]
300    pub fn builder() -> BarcodeBuilder {
301        BarcodeBuilder::default()
302    }
303}
304
305impl std::fmt::Display for Barcode {
306    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
307        use time::macros::format_description;
308
309        match self.version {
310            BarcodeVersion::V4 => {
311                write!(
312                    f,
313                    "4{}{:0>6}{:0>2}000{:0>20}{}",
314                    &self.account_number.as_str()[2..],
315                    self.euros,
316                    self.cents,
317                    self.reference,
318                    self.due_date.map_or("000000".into(), |d| d
319                        .format(format_description!(
320                            version = 2,
321                            "[year repr:last_two][month][day]"
322                        ))
323                        .expect("bug: formatting failed"))
324                )
325            }
326            BarcodeVersion::V5 => {
327                let ref_str = self.reference.clone();
328                let ref_nro = if ref_str.len() < 2 {
329                    format!("{:0<2}", self.reference)
330                } else {
331                    ref_str
332                };
333
334                write!(
335                    f,
336                    "5{}{:0>6}{:0>2}{}{:0>21}{}",
337                    &self.account_number.as_str()[2..],
338                    self.euros,
339                    self.cents,
340                    &ref_nro[..2],
341                    &ref_nro[2..],
342                    self.due_date.map_or("000000".into(), |d| d
343                        .format(format_description!(
344                            version = 2,
345                            "[year repr:last_two][month][day]"
346                        ))
347                        .expect("bug: formatting failed"))
348                )
349            }
350        }
351    }
352}