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}