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:
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;
}