taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

commit 1508d487199536c1af70f8e19c086b811e05a534
parent 1f36e0e09b4dec44fe3c87e908df2e8c8b130642
Author: Antoine A <>
Date:   Sat,  1 Feb 2025 14:36:11 +0100

magnet-bank: use iban payto with hungarian BBAN checks

Diffstat:
MCargo.lock | 4++--
Mcommon/taler-api/src/db.rs | 4++++
Mcommon/taler-common/src/types.rs | 2+-
Mcommon/taler-common/src/types/iban.rs | 139++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcommon/taler-common/src/types/payto.rs | 29++++++++++++++++++++++++++---
Mcommon/taler-common/src/types/utils.rs | 22++++++++++++++++++++++
Mtaler-magnet-bank/src/db.rs | 108+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mtaler-magnet-bank/src/dev.rs | 24++++++++++++++----------
Mtaler-magnet-bank/src/lib.rs | 188++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mtaler-magnet-bank/src/magnet.rs | 22+++++++++++-----------
Mtaler-magnet-bank/src/main.rs | 10++++++++--
Mtaler-magnet-bank/src/worker.rs | 40+++++++++++++++++++++-------------------
Mtaler-magnet-bank/tests/api.rs | 23++++++++++++++---------
13 files changed, 424 insertions(+), 191 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -304,9 +304,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.10" +version = "1.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13208fcbb66eaeffe09b99fffbe1af420f00a7b35aa99ad683dfc1aa76145229" +checksum = "e4730490333d58093109dc02c23174c3f4d490998c3fed3cc8e82d57afedb9cf" dependencies = [ "shlex", ] diff --git a/common/taler-api/src/db.rs b/common/taler-api/src/db.rs @@ -27,6 +27,7 @@ use taler_common::{ types::{ amount::{Amount, Decimal}, base32::Base32, + iban::IBAN, payto::Payto, timestamp::Timestamp, }, @@ -176,6 +177,9 @@ pub trait TypeHelper { fn try_get_payto<I: sqlx::ColumnIndex<Self>>(&self, index: I) -> sqlx::Result<Payto> { self.try_get_map(index, |s: &str| s.parse()) } + fn try_get_iban<I: sqlx::ColumnIndex<Self>>(&self, index: I) -> sqlx::Result<IBAN> { + self.try_get_map(index, |s: &str| s.parse()) + } fn try_get_amount(&self, index: &str, currency: &str) -> sqlx::Result<Amount>; fn try_get_amount_i(&self, index: usize, currency: &str) -> sqlx::Result<Amount>; } diff --git a/common/taler-common/src/types.rs b/common/taler-common/src/types.rs @@ -19,7 +19,7 @@ pub mod base32; pub mod iban; pub mod payto; pub mod timestamp; -mod utils; +pub mod utils; use url::Url; diff --git a/common/taler-common/src/types/iban.rs b/common/taler-common/src/types/iban.rs @@ -16,6 +16,7 @@ use std::{ fmt::{Debug, Display}, + ops::Deref, str::FromStr, }; @@ -24,15 +25,26 @@ use super::utils::InlineStr; const MAX_IBAN_SIZE: usize = 34; const MAX_BIC_SIZE: usize = 11; -#[derive(Clone, PartialEq, Eq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay)] +/// Parse an IBAN, panic if malformed +pub fn iban(iban: impl AsRef<str>) -> IBAN { + iban.as_ref().parse().expect("invalid IBAN") +} + +/// Parse an BIC, panic if malformed +pub fn bic(bic: impl AsRef<str>) -> BIC { + bic.as_ref().parse().expect("invalid BIC") +} + +#[derive( + Debug, Clone, PartialEq, Eq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay, +)] /// International Bank Account Number (IBAN) pub struct IBAN(InlineStr<MAX_IBAN_SIZE>); impl IBAN { /// Compute IBAN checksum - fn iban_checksum(s: &str) -> u32 { - s.as_bytes() - .iter() + fn iban_checksum(s: &[u8]) -> u8 { + (s.iter() .cycle() .skip(4) .take(s.len()) @@ -47,7 +59,47 @@ impl IBAN { } checksum }) - % 97 + % 97) as u8 + } + + pub fn from_parts(country: &str, bban: &str) -> Self { + assert_eq!(country.len(), 2); + // Create a iban with an empty digit check + let mut iban = InlineStr::from_iter( + country + .as_bytes() + .iter() + .copied() + .chain("00".as_bytes().iter().copied()) + .chain(bban.as_bytes().iter().copied()), + ) + .unwrap(); + // Compute check digit + let check_digit = 98 - Self::iban_checksum(iban.deref()); + + // And insert it + unsafe { + // SAFETY: we only insert ASCII digits + let buf = iban.deref_mut(); + buf[3] = check_digit % 10 + b'0'; + buf[2] = check_digit / 10 + b'0'; + } + Self(iban) + } + + pub fn country_code(&self) -> &str { + // SAFETY len >= 4 + unsafe { self.as_ref().get_unchecked(0..2) } + } + + pub fn check_digit(&self) -> &str { + // SAFETY len >= 4 + unsafe { self.as_ref().get_unchecked(2..4) } + } + + pub fn bban(&self) -> &str { + // SAFETY len >= 5 + unsafe { self.as_ref().get_unchecked(4..) } } } @@ -61,14 +113,14 @@ impl AsRef<str> for IBAN { pub enum IbanErrorKind { #[error("contains illegal characters (only 0-9A-Z allowed)")] Invalid, - #[error("contains invalid contry code")] + #[error("contains invalid country code")] CountryCode, #[error("contains invalid check digit")] CheckDigit, - #[error("too long (max {MAX_IBAN_SIZE} chars)")] - Big, + #[error("too long expected max {MAX_IBAN_SIZE} chars got {0}")] + Big(usize), #[error("checksum expected 1 got {0}")] - Checksum(u32), + Checksum(u8), } #[derive(Debug, thiserror::Error)] @@ -82,35 +134,32 @@ impl FromStr for IBAN { type Err = ParseIbanError; fn from_str(s: &str) -> Result<Self, Self::Err> { - (|| { - let bytes: &[u8] = s.as_bytes(); - if !bytes + let bytes: &[u8] = s.as_bytes(); + if !bytes + .iter() + .all(|b| b.is_ascii_whitespace() || b.is_ascii_alphanumeric()) + { + Err(IbanErrorKind::Invalid) + } else if let Some(inlined) = InlineStr::from_iter( + bytes .iter() - .all(|b| b.is_ascii_whitespace() || b.is_ascii_alphanumeric()) - { - return Err(IbanErrorKind::Invalid); - } - let Some(inlined) = InlineStr::from_iter( - bytes - .iter() - .filter_map(|b| (!b.is_ascii_whitespace()).then_some(b.to_ascii_uppercase())), - ) else { - return Err(IbanErrorKind::Big); - }; - let str = inlined.as_ref(); - let bytes = str.as_bytes(); - if bytes.len() < 2 || !bytes[0..2].iter().all(u8::is_ascii_uppercase) { - return Err(IbanErrorKind::CountryCode); - } else if bytes.len() < 4 || !bytes[2..4].iter().all(u8::is_ascii_digit) { - return Err(IbanErrorKind::CheckDigit); - } - let checksum = Self::iban_checksum(str); - if checksum != 1 { - Err(IbanErrorKind::Checksum(checksum)) + .filter_map(|b| (!b.is_ascii_whitespace()).then_some(b.to_ascii_uppercase())), + ) { + if inlined.len() < 2 || !inlined[0..2].iter().all(u8::is_ascii_uppercase) { + Err(IbanErrorKind::CountryCode) + } else if inlined.len() < 4 || !inlined[2..4].iter().all(u8::is_ascii_digit) { + Err(IbanErrorKind::CheckDigit) } else { - Ok(Self(inlined)) + let checksum = Self::iban_checksum(&inlined); + if checksum != 1 { + Err(IbanErrorKind::Checksum(checksum)) + } else { + Ok(Self(inlined)) + } } - })() + } else { + Err(IbanErrorKind::Big(bytes.len())) + } .map_err(|kind| ParseIbanError { iban: s.to_owned(), kind, @@ -118,12 +167,6 @@ impl FromStr for IBAN { } } -impl Debug for IBAN { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Debug::fmt(&self.as_ref(), f) - } -} - impl Display for IBAN { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { Display::fmt(&self.as_ref(), f) @@ -131,7 +174,9 @@ impl Display for IBAN { } /// Bank Identifier Code (BIC) -#[derive(Clone, PartialEq, Eq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay)] +#[derive( + Debug, Clone, PartialEq, Eq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay, +)] pub struct BIC(InlineStr<MAX_BIC_SIZE>); impl BIC { @@ -209,12 +254,6 @@ impl FromStr for BIC { } } -impl Debug for BIC { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Debug::fmt(&self.as_ref(), f) - } -} - impl Display for BIC { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { Display::fmt(&self.as_ref(), f) @@ -235,8 +274,12 @@ fn parse_iban() { "PL61109010140000071219812874", // Poland "NO9386011117947", // Norway ] { + // Parsing let iban = IBAN::from_str(&valid).unwrap(); assert_eq!(iban.to_string(), valid); + // Roundtrip + let from_parts = IBAN::from_parts(iban.country_code(), iban.bban()); + assert_eq!(from_parts.to_string(), valid); } for (invalid, err) in [ diff --git a/common/taler-common/src/types/payto.rs b/common/taler-common/src/types/payto.rs @@ -39,15 +39,28 @@ impl Payto { self.0.as_str() } + pub fn full(self, name: impl AsRef<str>) -> Self { + self.with_query([("receiver-name", name.as_ref())]) + } + + pub fn from_parts(domain: &str, path: impl Display) -> Self { + payto(format!("payto://{domain}{path}")) + } + pub fn query<Q: DeserializeOwned>(&self) -> Result<Q, PaytoErr> { let query = self.0.query().unwrap_or_default().as_bytes(); let de = serde_urlencoded::Deserializer::new(url::form_urlencoded::parse(query)); serde_path_to_error::deserialize(de).map_err(PaytoErr::Query) } - pub fn from_parts(domain_path: impl Display, query: impl Serialize) -> Self { - let query = serde_urlencoded::to_string(query).unwrap(); - payto(format!("payto://{domain_path}?{query}")) + fn with_query(mut self, query: impl Serialize) -> Self { + let mut urlencoder = self.0.query_pairs_mut(); + query + .serialize(serde_urlencoded::Serializer::new(&mut urlencoder)) + .unwrap(); + let _ = urlencoder.finish(); + drop(urlencoder); + self } } @@ -105,6 +118,16 @@ pub struct IbanPayto { pub bic: Option<BIC>, } +impl IbanPayto { + pub fn as_payto(&self) -> Payto { + Payto::from_parts("iban", format_args!("/{}", self.iban)) + } + + pub fn as_full_payto(&self, name: &str) -> Payto { + self.as_payto().full(name) + } +} + #[derive(Debug, thiserror::Error)] pub enum IbanPaytoErr { #[error("missing IBAN in path")] diff --git a/common/taler-common/src/types/utils.rs b/common/taler-common/src/types/utils.rs @@ -14,6 +14,8 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +use std::{fmt::Debug, ops::Deref}; + #[derive(Clone, PartialEq, Eq)] pub struct InlineStr<const LEN: usize> { /// Len of ascii string in buf @@ -53,6 +55,11 @@ impl<const LEN: usize> InlineStr<LEN> { buf, }) } + + pub unsafe fn deref_mut(&mut self) -> &mut [u8] { + // SAFETY: len <= LEN + unsafe { self.buf.get_unchecked_mut(..self.len as usize) } + } } impl<const LEN: usize> AsRef<str> for InlineStr<LEN> { @@ -62,3 +69,18 @@ impl<const LEN: usize> AsRef<str> for InlineStr<LEN> { unsafe { std::str::from_utf8_unchecked(self.buf.get_unchecked(..self.len as usize)) } } } + +impl<const LEN: usize> Debug for InlineStr<LEN> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Debug::fmt(&self.as_ref(), f) + } +} + +impl<const LEN: usize> Deref for InlineStr<LEN> { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + // SAFETY: len <= LEN + unsafe { self.buf.get_unchecked(..self.len as usize) } + } +} diff --git a/taler-magnet-bank/src/db.rs b/taler-magnet-bank/src/db.rs @@ -28,11 +28,11 @@ use taler_common::{ IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferRequest, TransferState, TransferStatus, }, - types::{amount::Amount, timestamp::Timestamp}, + types::{amount::Amount, iban::IBAN, payto::IbanPayto, timestamp::Timestamp}, }; use tokio::sync::watch::{Receiver, Sender}; -use crate::{constant::CURRENCY, MagnetPayto}; +use crate::{constant::CURRENCY, HuIban, MagnetPayto}; pub async fn notification_listener( pool: PgPool, @@ -165,7 +165,7 @@ pub async fn register_tx_in_admin(db: &PgPool, tx: &TxInAdmin) -> sqlx::Result<A ) .bind_amount(&tx.amount) .bind(&tx.subject) - .bind(&tx.debtor.number) + .bind(tx.debtor.iban()) .bind(&tx.debtor.name) .bind_timestamp(&tx.timestamp) .bind(tx.metadata.ty()) @@ -199,7 +199,7 @@ pub async fn register_tx_in( .bind(tx.code as i64) .bind_amount(&tx.amount) .bind(&tx.subject) - .bind(&tx.debtor.number) + .bind(tx.debtor.iban()) .bind(&tx.debtor.name) .bind_timestamp(&tx.timestamp) .bind(subject.as_ref().map(|it| it.ty())) @@ -233,7 +233,7 @@ pub async fn register_tx_out( .bind(tx.code as i64) .bind_amount(&tx.amount) .bind(&tx.subject) - .bind(&tx.creditor.number) + .bind(tx.creditor.iban()) .bind(&tx.creditor.name) .bind_timestamp(&tx.timestamp) .bind(subject.as_ref().map(|it| it.0.as_ref())) @@ -274,7 +274,7 @@ pub async fn make_transfer<'a>( .bind(&subject) .bind_amount(&req.amount) .bind(req.exchange_base_url.as_str()) - .bind(&creditor.number) + .bind(creditor.iban()) .bind(&creditor.name) .bind_timestamp(timestamp) .try_map(|r: PgRow| { @@ -328,11 +328,11 @@ pub async fn transfer_page<'a>( row_id: r.try_get_safeu64(0)?, status: r.try_get(1)?, amount: r.try_get_amount_i(2, CURRENCY)?, - credit_account: MagnetPayto { - number: r.try_get(4)?, - name: r.try_get(4)?, + credit_account: IbanPayto { + iban: r.try_get_iban(4)?, + bic: None, } - .as_payto(), + .as_full_payto(r.try_get(5)?), timestamp: r.try_get_timestamp(6)?, }) }, @@ -372,11 +372,11 @@ pub async fn outgoing_history( Ok(OutgoingBankTransaction { row_id: r.try_get_safeu64(0)?, amount: r.try_get_amount_i(1, CURRENCY)?, - credit_account: MagnetPayto { - number: r.try_get(3)?, - name: r.try_get(4)?, + credit_account: IbanPayto { + iban: r.try_get_iban(3)?, + bic: None, } - .as_payto(), + .as_full_payto(r.try_get(4)?), date: r.try_get_timestamp(5)?, exchange_base_url: r.try_get_url(6)?, wtid: r.try_get_base32(7)?, @@ -419,22 +419,22 @@ pub async fn incoming_history( IncomingType::reserve => IncomingBankTransaction::Reserve { row_id: r.try_get_safeu64(1)?, amount: r.try_get_amount_i(2, CURRENCY)?, - debit_account: MagnetPayto { - number: r.try_get(4)?, - name: r.try_get(5)?, + debit_account: IbanPayto { + iban: r.try_get_iban(4)?, + bic: None, } - .as_payto(), + .as_full_payto(r.try_get(5)?), date: r.try_get_timestamp(6)?, reserve_pub: r.try_get_base32(7)?, }, IncomingType::kyc => IncomingBankTransaction::Kyc { row_id: r.try_get_safeu64(1)?, amount: r.try_get_amount_i(2, CURRENCY)?, - debit_account: MagnetPayto { - number: r.try_get(4)?, - name: r.try_get(5)?, + debit_account: IbanPayto { + iban: r.try_get_iban(4)?, + bic: None, } - .as_payto(), + .as_full_payto(r.try_get(5)?), date: r.try_get_timestamp(6)?, account_pub: r.try_get_base32(7)?, }, @@ -479,11 +479,11 @@ pub async fn revenue_history( date: r.try_get_timestamp(1)?, amount: r.try_get_amount_i(2, CURRENCY)?, credit_fee: None, - debit_account: MagnetPayto { - number: r.try_get(4)?, - name: r.try_get(5)?, + debit_account: IbanPayto { + iban: r.try_get_iban(4)?, + bic: None, } - .as_payto(), + .as_full_payto(r.try_get(5)?), subject: r.try_get(6)?, }) }, @@ -520,11 +520,11 @@ pub async fn transfer_by_id<'a>( amount: r.try_get_amount_i(2, CURRENCY)?, origin_exchange_url: r.try_get(4)?, wtid: r.try_get_base32(5)?, - credit_account: MagnetPayto { - number: r.try_get(6)?, - name: r.try_get(7)?, + credit_account: IbanPayto { + iban: r.try_get_iban(6)?, + bic: None, } - .as_payto(), + .as_full_payto(r.try_get(7)?), timestamp: r.try_get_timestamp(8)?, }) }) @@ -551,7 +551,11 @@ pub async fn pending_batch<'a>( amount: r.try_get_amount_i(1, CURRENCY)?, subject: r.try_get(3)?, creditor: MagnetPayto { - number: r.try_get(4)?, + iban: r.try_get_map(4, |s: &str| { + let iban: IBAN = s.parse()?; + let it = HuIban::try_from(iban)?; + anyhow::Ok(it) + })?, name: r.try_get(5)?, }, }) @@ -624,7 +628,7 @@ mod test { self, make_transfer, register_tx_in, register_tx_in_admin, register_tx_out, AddIncomingResult, RegisteredTx, TransferResult, TxIn, TxOut, }, - MagnetPayto, + magnet_payto, }; use super::TxInAdmin; @@ -659,10 +663,9 @@ mod test { code: code, amount: amount("EUR:10"), subject: "subject".to_owned(), - debtor: MagnetPayto { - number: "number".to_owned(), - name: "name".to_owned(), - }, + debtor: magnet_payto( + "payto://iban/HU30162000031000163100000000?receiver-name=name", + ), timestamp: Timestamp::now_stable(), }; // Insert @@ -779,10 +782,7 @@ mod test { let tx = TxInAdmin { amount: amount("EUR:10"), subject: "subject".to_owned(), - debtor: MagnetPayto { - number: "number".to_owned(), - name: "name".to_owned(), - }, + debtor: magnet_payto("payto://iban/HU30162000031000163100000000?receiver-name=name"), timestamp: Timestamp::now_stable(), metadata: IncomingSubject::Reserve(EddsaPublicKey::rand()), }; @@ -862,10 +862,9 @@ mod test { code, amount: amount("EUR:10"), subject: "subject".to_owned(), - creditor: MagnetPayto { - number: "number".to_owned(), - name: "name".to_owned(), - }, + creditor: magnet_payto( + "payto://iban/HU30162000031000163100000000?receiver-name=name", + ), timestamp: Timestamp::now_stable(), }; // Insert @@ -970,12 +969,9 @@ mod test { amount: amount("EUR:10"), exchange_base_url: url("https://exchange.test.com/"), wtid: ShortHashCode::rand(), - credit_account: payto("payto://magnet-bank/todo"), - }; - let payto = MagnetPayto { - number: "number".to_owned(), - name: "name".to_owned(), + credit_account: payto("payto://iban/HU02162000031000164800000000?receiver-name=name"), }; + let payto = magnet_payto("payto://iban/HU30162000031000163100000000?receiver-name=name"); let timestamp = Timestamp::now_stable(); // Insert assert_eq!( @@ -1077,10 +1073,8 @@ mod test { async fn batch() { let (mut db, _) = setup().await; let start = Timestamp::now(); - let magnet_payto = MagnetPayto { - number: "number".to_owned(), - name: "name".to_owned(), - }; + let magnet_payto = + magnet_payto("payto://iban/HU30162000031000163100000000?receiver-name=name"); // Empty db let pendings = db::pending_batch(&mut db, &start) @@ -1097,7 +1091,9 @@ mod test { amount: amount(format!("{CURRENCY}:{}", i + 1)), exchange_base_url: url("https://exchange.test.com/"), wtid: ShortHashCode::rand(), - credit_account: payto("payto://magnet-bank/todo"), + credit_account: payto( + "payto://iban/HU02162000031000164800000000?receiver-name=name", + ), }, &magnet_payto, &&Timestamp::now(), @@ -1119,7 +1115,9 @@ mod test { amount: amount(format!("{CURRENCY}:{}", i + 1)), exchange_base_url: url("https://exchange.test.com/"), wtid: ShortHashCode::rand(), - credit_account: payto("payto://magnet-bank/todo"), + credit_account: payto( + "payto://iban/HU02162000031000164800000000?receiver-name=name", + ), }, &magnet_payto, &Timestamp::now(), diff --git a/taler-magnet-bank/src/dev.rs b/taler-magnet-bank/src/dev.rs @@ -20,7 +20,8 @@ use taler_common::{ config::Config, types::{ amount::Amount, - payto::{FullQuery, Payto}, + iban::IBAN, + payto::{FullQuery, IbanPayto, Payto}, }, }; use tracing::info; @@ -73,11 +74,14 @@ pub async fn dev(cfg: Config, cmd: DevCmd) -> anyhow::Result<()> { let res = client.list_accounts().await?; for partner in res.partners { for account in partner.bank_accounts { - let payto = MagnetPayto { - number: account.number, - name: partner.partner.name.clone(), - }; - info!("{} {} {payto}", account.code, account.currency.symbol); + let iban: IBAN = account.iban.parse()?; + let payto = IbanPayto { iban, bic: None }; + info!( + "{} {} {}", + account.code, + account.currency.symbol, + payto.as_full_payto(&partner.partner.name) + ); } } } @@ -92,7 +96,7 @@ pub async fn dev(cfg: Config, cmd: DevCmd) -> anyhow::Result<()> { let mut next = None; loop { let page = client - .page_tx(dir, 5, &account.number, &next, &None) + .page_tx(dir, 5, account.bban(), &next, &None) .await?; next = page.next; for item in page.list { @@ -116,7 +120,7 @@ pub async fn dev(cfg: Config, cmd: DevCmd) -> anyhow::Result<()> { let full: FullQuery = creditor.query()?; let debtor = MagnetPayto::try_from(&debtor)?; let creditor = MagnetPayto::try_from(&creditor)?; - let debtor = client.account(&debtor.number).await?; + let debtor = client.account(debtor.bban()).await?; let now = Zoned::now(); let date = now.date(); @@ -127,7 +131,7 @@ pub async fn dev(cfg: Config, cmd: DevCmd) -> anyhow::Result<()> { &subject, &date, &full.receiver_name, - &creditor.number, + creditor.bban(), ) .await? .tx; @@ -138,7 +142,7 @@ pub async fn dev(cfg: Config, cmd: DevCmd) -> anyhow::Result<()> { init.code, init.amount, &date, - &creditor.number, + creditor.bban(), ) .await?; } diff --git a/taler-magnet-bank/src/lib.rs b/taler-magnet-bank/src/lib.rs @@ -14,7 +14,12 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use taler_common::types::payto::{FullQuery, Payto, PaytoErr}; +use std::borrow::Cow; + +use taler_common::types::{ + iban::IBAN, + payto::{FullQuery, IbanPayto, Payto, PaytoErr}, +}; pub mod adapter; pub mod config; @@ -30,18 +35,123 @@ pub mod failure_injection { } } +pub const MAX_MAGNET_BBAN_SIZE: usize = 24; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HuIban(IBAN); + +impl HuIban { + pub fn checksum(b: &[u8]) -> Result<(), (u8, u8)> { + let expected_digit = b[7] - b'0'; + let sum = ((b[0] - b'0') * 9) as u16 + + ((b[1] - b'0') * 7) as u16 + + ((b[2] - b'0') * 3) as u16 + + ((b[3] - b'0') * 1) as u16 + + ((b[4] - b'0') * 9) as u16 + + ((b[5] - b'0') * 7) as u16 + + ((b[6] - b'0') * 3) as u16; + let modulo = ((10 - (sum % 10)) % 10) as u8; + if expected_digit != modulo { + Err((expected_digit, modulo)) + } else { + Ok(()) + } + } + + fn check_bban(bban: &str) -> Result<(), HuIbanErr> { + let bban = bban.strip_suffix("00000000").unwrap_or(bban).as_bytes(); + if bban.len() != 16 && bban.len() != 24 { + return Err(HuIbanErr::BbanSize(bban.len())); + } else if !bban.iter().all(u8::is_ascii_digit) { + return Err(HuIbanErr::Invalid); + } + Self::checksum(&bban[..8]).map_err(|e| HuIbanErr::checksum("bank-branch number", e))?; + if bban.len() == 16 { + Self::checksum(&bban[8..]).map_err(|e| HuIbanErr::checksum("account number", e))?; + } else { + Self::checksum(&bban[8..16]) + .map_err(|e| HuIbanErr::checksum("account number first group", e))?; + Self::checksum(&bban[16..]) + .map_err(|e| HuIbanErr::checksum("account number second group", e))?; + } + Ok(()) + } + + pub fn from_bban(bban: &str) -> Result<Self, HuIbanErr> { + Self::check_bban(bban)?; + let full_bban = if bban.len() == 16 { + Cow::Owned(format!("{bban}00000000")) + } else { + Cow::Borrowed(bban) + }; + let iban = IBAN::from_parts("HU", &full_bban); + Ok(Self(iban)) + } + + pub fn bban(&self) -> &str { + let bban = self.0.bban(); + bban.strip_suffix("00000000").unwrap_or(bban) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum HuIbanErr { + #[error("contains illegal characters (only 0-9 allowed)")] + Invalid, + #[error("expected an hungarian IBAN starting with HU got {0}")] + CountryCode(String), + #[error("invalid length expected 16 or 24 chars got {0}")] + BbanSize(usize), + #[error("invalid checkum for {0} expected {1} got {2}")] + Checksum(&'static str, u8, u8), +} + +impl HuIbanErr { + fn checksum(part: &'static str, (expected, checksum): (u8, u8)) -> Self { + Self::Checksum(part, expected, checksum) + } +} + +impl TryFrom<IBAN> for HuIban { + type Error = HuIbanErr; + + fn try_from(iban: IBAN) -> Result<Self, Self::Error> { + let country_code = iban.country_code(); + if country_code != "HU" { + return Err(HuIbanErr::CountryCode(country_code.to_owned())); + } + + dbg!(iban.country_code(), iban.bban()); + + Self::check_bban(iban.bban())?; + + Ok(Self(iban)) + } +} + +/// Parse a magnet payto URI, panic if malformed +pub fn magnet_payto(url: impl AsRef<str>) -> MagnetPayto { + let payto: Payto = url.as_ref().parse().expect("invalid payto"); + (&payto).try_into().expect("invalid magnet payto") +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MagnetPayto { - pub number: String, + pub iban: HuIban, pub name: String, } impl MagnetPayto { pub fn as_payto(&self) -> Payto { - Payto::from_parts( - format_args!("{MAGNET_BANK}/{}", self.number), - [("receiver-name", &self.name)], - ) + Payto::from_parts("iban", format_args!("/{}", self.iban.0)).full(&self.name) + } + + pub fn iban(&self) -> &str { + self.iban.0.as_ref() + } + + pub fn bban(&self) -> &str { + self.iban.bban() } } @@ -51,38 +161,54 @@ impl std::fmt::Display for MagnetPayto { } } -#[derive(Debug, thiserror::Error)] -pub enum MagnetPaytoErr { - #[error("missing Magnet Bank account number in path")] - MissingAccount, -} - -const MAGNET_BANK: &str = "magnet-bank"; - impl TryFrom<&Payto> for MagnetPayto { type Error = PaytoErr; fn try_from(value: &Payto) -> Result<Self, Self::Error> { - let url = value.as_ref(); - if url.domain() != Some(MAGNET_BANK) { - return Err(PaytoErr::UnsupportedKind( - MAGNET_BANK, - url.domain().unwrap_or_default().to_owned(), - )); - } - let Some(mut segments) = url.path_segments() else { - return Err(PaytoErr::custom(MagnetPaytoErr::MissingAccount)); - }; - let Some(account) = segments.next() else { - return Err(PaytoErr::custom(MagnetPaytoErr::MissingAccount)); - }; - if segments.next().is_some() { - return Err(PaytoErr::TooLong(MAGNET_BANK)); - } + let iban_payto = IbanPayto::try_from(value).map_err(PaytoErr::custom)?; + let hu_iban = HuIban::try_from(iban_payto.iban).map_err(PaytoErr::custom)?; let full: FullQuery = value.query()?; Ok(Self { - number: account.to_owned(), + iban: hu_iban, name: full.receiver_name, }) } } + +#[cfg(test)] +mod test { + use taler_common::types::payto::{payto, IbanPayto}; + + use crate::HuIban; + + #[test] + fn hu_iban() { + for (valid, account) in [ + ( + payto("payto://iban/HU30162000031000163100000000"), + "1620000310001631", + ), + ( + payto("payto://iban/HU02162000031000164800000000"), + "1620000310001648", + ), + ( + payto("payto://iban/HU60162000101006446300000000"), + "1620001010064463", + ), + ] { + // Parsing + let iban_payto: IbanPayto = (&valid).try_into().unwrap(); + let hu_payto: HuIban = iban_payto.iban.try_into().unwrap(); + assert_eq!(hu_payto.bban(), account); + // Roundtrip + let iban = HuIban::from_bban(&account).unwrap(); + let payto = IbanPayto { + iban: iban.0, + bic: None, + } + .as_payto(); + assert_eq!(payto, valid); + } + } +} diff --git a/taler-magnet-bank/src/magnet.rs b/taler-magnet-bank/src/magnet.rs @@ -394,10 +394,10 @@ impl ApiClient<'_> { .await } - pub async fn account(&self, account: &str) -> ApiResult<Account> { + pub async fn account(&self, bban: &str) -> ApiResult<Account> { Ok(self .client - .get(self.join(&format!("/RESTApi/resources/v2/bankszamla/{account}"))) + .get(self.join(&format!("/RESTApi/resources/v2/bankszamla/{bban}"))) .oauth(self.consumer, Some(self.access), None) .await .magnet_json::<AccountWrapper>() @@ -409,12 +409,12 @@ impl ApiClient<'_> { &self, direction: Direction, limit: u16, - account: &str, + bban: &str, next: &Option<Next>, status: &Option<TxStatus>, ) -> ApiResult<TransactionPage> { let mut req = self.client.get(self.join(&format!( - "/RESTApi/resources/v2/tranzakcio/paginator/{account}/{limit}" + "/RESTApi/resources/v2/tranzakcio/paginator/{bban}/{limit}" ))); if let Some(next) = next { req = req @@ -443,7 +443,7 @@ impl ApiClient<'_> { subject: &str, date: &jiff::civil::Date, creditor_name: &str, - creditor_account: &str, + creditor_bban: &str, ) -> ApiResult<TxInfo> { #[derive(Serialize)] struct Req<'a> { @@ -470,7 +470,7 @@ impl ApiClient<'_> { subject, date, creditor_name, - creditor_account, + creditor_account: creditor_bban, }) .oauth(self.consumer, Some(self.access), None) .await @@ -482,11 +482,11 @@ impl ApiClient<'_> { pub async fn sign_tx( &self, signing_key: &SigningKey, - account: &str, + bban: &str, tx_code: u64, amount: f64, date: &jiff::civil::Date, - creditor: &str, + creditor_bban: &str, ) -> ApiResult<TxInfo> { #[derive(Serialize)] struct Req<'a> { @@ -503,7 +503,7 @@ impl ApiClient<'_> { signature: &'a str, } - let content: String = format!("{tx_code};{account};{creditor};{amount};{date};"); + let content: String = format!("{tx_code};{bban};{creditor_bban};{amount};{date};"); let signature: DerSignature = signing_key.sign(content.as_bytes()); let encoded = BASE64_STANDARD.encode(signature.as_bytes()); Ok(self @@ -511,8 +511,8 @@ impl ApiClient<'_> { .put(self.join("/RESTApi/resources/v2/tranzakcio/alairas")) .json(&Req { tx_code, - debtor: account, - creditor, + debtor: bban, + creditor: creditor_bban, amount, date, signature: &encoded, diff --git a/taler-magnet-bank/src/main.rs b/taler-magnet-bank/src/main.rs @@ -115,7 +115,13 @@ async fn app(args: Args, cfg: Config) -> anyhow::Result<()> { let db = DbCfg::parse(&cfg)?; let pool = PgPool::connect_with(db.cfg).await?; let cfg = ServeCfg::parse(&cfg)?; - let api = Arc::new(MagnetApi::start(pool, payto("payto://magnet-bank/todo")).await); + let api = Arc::new( + MagnetApi::start( + pool, + payto("payto://iban/HU02162000031000164800000000?receiver-name=name"), + ) + .await, + ); let mut builder = TalerApiBuilder::new(); if let Some(cfg) = cfg.wire_gateway { builder = builder.wire_gateway(api.clone(), cfg.auth); @@ -138,7 +144,7 @@ async fn app(args: Args, cfg: Config) -> anyhow::Result<()> { let client = AuthClient::new(&client, &cfg.api_url, &cfg.consumer).upgrade(&keys.access_token); let account = MagnetPayto::try_from(&account)?; - let account = client.account(&account.number).await?; + let account = client.account(account.bban()).await?; let mut db = pool.acquire().await?.detach(); // TODO run in loop and handle errors let mut worker = Worker { diff --git a/taler-magnet-bank/src/worker.rs b/taler-magnet-bank/src/worker.rs @@ -20,6 +20,7 @@ use sqlx::PgConnection; use taler_api::subject::{self, parse_incoming_unstructured}; use taler_common::types::{ amount::{self}, + iban::IBAN, timestamp::Timestamp, }; use tracing::{debug, info}; @@ -28,7 +29,7 @@ use crate::{ db::{self, AddIncomingResult, Initiated, TxIn, TxOut}, failure_injection::fail_point, magnet::{error::ApiError, ApiClient, Direction, Transaction}, - MagnetPayto, + HuIban, MagnetPayto, }; #[derive(Debug, thiserror::Error)] @@ -168,7 +169,7 @@ impl Worker<'_> { &tx.subject, &date, &tx.creditor.name, - &tx.creditor.number, + tx.creditor.bban(), ) .await? .tx; @@ -179,7 +180,7 @@ impl Worker<'_> { db::initiated_submit_success(&mut *self.db, tx.id, &Timestamp::now(), info.code).await?; // Sign transaction - self.sign_tx(info.code, info.amount, &date, &tx.creditor.number) + self.sign_tx(info.code, info.amount, &date, tx.creditor.bban()) .await?; Ok(()) } @@ -218,31 +219,32 @@ pub enum Tx { pub fn extract_tx_info(tx: Transaction) -> Tx { // TODO amount from f64 without allocations let amount = amount::amount(format!("{}:{}", tx.currency, tx.amount.abs())); + // TODO we should support non hungarian account and error handling + let iban = if tx.counter_account.starts_with("HU") { + let iban: IBAN = tx.counter_account.parse().unwrap(); + HuIban::try_from(iban).unwrap() + } else { + HuIban::from_bban(&tx.counter_account).unwrap() + }; + let counter_account = MagnetPayto { + iban, + name: tx.counter_name, + }; if tx.amount.is_sign_positive() { - let tx = TxIn { + Tx::In(TxIn { code: tx.code, amount, subject: tx.subject, - // TODO this is our account, we only have the account number of the debtor - debtor: MagnetPayto { - number: tx.counter_account, - name: tx.counter_name, - }, + debtor: counter_account, timestamp: Timestamp::from(tx.value_date), - }; - Tx::In(tx) + }) } else { - let tx = TxOut { + Tx::Out(TxOut { code: tx.code, amount, subject: tx.subject, - // TODO this is our account, we only have the account number of the debtor - creditor: MagnetPayto { - number: tx.counter_account, - name: tx.counter_name, - }, + creditor: counter_account, timestamp: Timestamp::from(tx.value_date), - }; - Tx::Out(tx) + }) } } diff --git a/taler-magnet-bank/tests/api.rs b/taler-magnet-bank/tests/api.rs @@ -23,7 +23,7 @@ use taler_common::{ api_wire::{OutgoingHistory, TransferState}, types::{amount::amount, payto::payto, timestamp::Timestamp, url}, }; -use taler_magnet_bank::{adapter::MagnetApi, db, MagnetPayto}; +use taler_magnet_bank::{adapter::MagnetApi, db, magnet_payto}; use taler_test_utils::{ axum_test::TestServer, db_test_setup, @@ -33,7 +33,13 @@ use taler_test_utils::{ async fn setup() -> (TestServer, PgPool) { let pool = db_test_setup().await; db::db_init(&pool, false).await.unwrap(); - let api = Arc::new(MagnetApi::start(pool.clone(), payto("payto://magnet-bank/todo")).await); + let api = Arc::new( + MagnetApi::start( + pool.clone(), + payto("payto://iban/HU02162000031000164800000000?receiver-name=name"), + ) + .await, + ); let builder = TalerApiBuilder::new() .wire_gateway(api.clone(), AuthMethod::None) .revenue(api, AuthMethod::None) @@ -49,7 +55,7 @@ async fn transfer() { transfer_routine( &server, TransferState::pending, - &payto("payto://magnet-bank/account?receiver-name=John+Smith"), + &payto("payto://iban/HU02162000031000164800000000?receiver-name=name"), ) .await; } @@ -76,10 +82,9 @@ async fn outgoing_history() { code: i as u64, amount: amount("EUR:10"), subject: "subject".to_owned(), - creditor: MagnetPayto { - number: "number".to_owned(), - name: "name".to_owned(), - }, + creditor: magnet_payto( + "payto://iban/HU30162000031000163100000000?receiver-name=name", + ), timestamp: Timestamp::now_stable(), }, &Some(OutgoingSubject( @@ -100,7 +105,7 @@ async fn admin_add_incoming() { let (server, _) = setup().await; admin_add_incoming_routine( &server, - &payto("payto://magnet-bank/account?receiver-name=John+Smith"), + &payto("payto://iban/HU02162000031000164800000000?receiver-name=name"), ) .await; } @@ -110,7 +115,7 @@ async fn revenue() { let (server, _) = setup().await; revenue_routine( &server, - &payto("payto://magnet-bank/account?receiver-name=John+Smith"), + &payto("payto://iban/HU02162000031000164800000000?receiver-name=name"), ) .await; }