bankserver_rust/src/api/transactions.rs
2025-04-03 20:14:18 +02:00

604 lines
18 KiB
Rust

use std::{borrow::Cow, sync::Arc};
use axum::{Router, http::StatusCode, routing::post};
use bank_core::{ApiError, Name, NameOrUuid, make_schemas, transaction::Transaction};
use deadpool_postgres::GenericClient;
use garde::Validate;
use schemars::JsonSchema;
use serde::Deserialize;
use tokio_postgres::Statement;
use tracing::{error, instrument};
use uuid::Uuid;
use crate::model::{Accounts, Transactions, Users};
use super::{
AppState, EState, Error, InteropState, Json, State,
auth::Auth,
socket::{SocketEvent, SocketMessage},
};
pub(super) fn router() -> Router<Arc<AppState>> {
Router::new().route("/", post(make_payment))
}
make_schemas!((MakePayment); ());
#[derive(Debug, Validate, PartialEq)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct AccountSelector {
#[garde(dive)]
pub user: NameOrUuid,
#[garde(dive)]
pub account: Option<Name>,
}
impl AccountSelector {
#[instrument(skip(client))]
pub async fn account_id(
&self,
client: &impl GenericClient,
) -> Result<Option<(Uuid, Uuid)>, tokio_postgres::Error> {
let user_id = match &self.user {
NameOrUuid::Id(uuid) => *uuid,
NameOrUuid::Name(name) => match Users::info_by_name(client, &*name).await? {
Some(info) => info.id,
None => return Ok(None),
},
};
let account_id = match self.account.as_ref() {
Some(name) => match Accounts::get_for_user(client, user_id, &*name).await? {
Some(info) => info.id,
None => return Ok(None),
},
None => user_id,
};
Ok(Some((user_id, account_id)))
}
}
#[doc(hidden)]
#[derive(Debug, Deserialize, Validate, PartialEq)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum UnvalidatedAccountSelector {
Username(#[garde(dive)] Name),
Object {
#[garde(dive)]
user: NameOrUuid,
#[garde(dive)]
account: Option<Name>,
},
}
impl<'de> Deserialize<'de> for AccountSelector {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(
match UnvalidatedAccountSelector::deserialize(deserializer)? {
UnvalidatedAccountSelector::Username(name) => Self {
user: NameOrUuid::Name(name),
account: None,
},
UnvalidatedAccountSelector::Object { user, account } => Self { user, account },
},
)
}
}
pub trait ValidateTransform: Sized {
type Src: Validate;
fn schema_name() -> Cow<'static, str>;
fn schema_id() -> Cow<'static, str> {
Cow::Borrowed(std::any::type_name::<Self>())
}
fn validate_transform_into(
src: Self::Src,
ctx: &<Self::Src as Validate>::Context,
parent: &mut dyn FnMut() -> garde::Path,
report: &mut garde::Report,
) -> Option<Self>;
}
#[derive(Debug, PartialEq)]
pub struct UnvalidatedTransform<Dest: ValidateTransform> {
src: Dest::Src,
}
impl<'de, Dest> Deserialize<'de> for UnvalidatedTransform<Dest>
where
Dest: ValidateTransform,
Dest::Src: Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(UnvalidatedTransform {
src: <Dest::Src as Deserialize<'de>>::deserialize(deserializer)?,
})
}
}
#[derive(Debug, PartialEq)]
pub enum AccountTarget {
Selector(AccountSelector),
Interop(String),
}
#[derive(Debug, PartialEq, Validate)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemas", serde(untagged))]
pub enum UnvalidatedAccountTarget {
/// Interop user
Interop(#[garde(pattern("^.{2,}@[a-z0-9]{2,4}$"))] String),
Selector(#[garde(dive)] UnvalidatedAccountSelector),
}
impl<'de> Deserialize<'de> for UnvalidatedAccountTarget {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum AccountTargetHelper {
Text(String),
Selector(UnvalidatedAccountSelector),
}
Ok(match AccountTargetHelper::deserialize(deserializer)? {
AccountTargetHelper::Text(text) => {
// TODO: don't hardcode prefix
if let Some(text) = text.strip_suffix("@thc") {
Self::Interop(text.into())
} else {
Self::Selector(UnvalidatedAccountSelector::Username(Name(text)))
}
}
AccountTargetHelper::Selector(selector) => Self::Selector(selector),
})
}
}
impl ValidateTransform for AccountTarget {
type Src = UnvalidatedAccountTarget;
fn schema_name() -> Cow<'static, str> {
Cow::Borrowed("AccountTarget")
}
fn validate_transform_into(
src: Self::Src,
ctx: &<Self::Src as Validate>::Context,
parent: &mut dyn FnMut() -> garde::Path,
report: &mut garde::Report,
) -> Option<Self> {
match src {
UnvalidatedAccountTarget::Interop(user) => Some(AccountTarget::Interop(user)),
UnvalidatedAccountTarget::Selector(selector) => {
AccountSelector::validate_transform_into(selector, ctx, parent, report)
.map(Self::Selector)
}
}
}
}
impl<Dest> schemars::JsonSchema for UnvalidatedTransform<Dest>
where
Dest: ValidateTransform,
Dest::Src: JsonSchema,
{
fn schema_name() -> Cow<'static, str> {
Dest::schema_name()
}
fn schema_id() -> Cow<'static, str> {
Dest::schema_id()
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
<Dest::Src as schemars::JsonSchema>::json_schema(generator)
}
fn always_inline_schema() -> bool {
<Dest::Src as schemars::JsonSchema>::always_inline_schema()
}
}
impl<Dest: ValidateTransform> UnvalidatedTransform<Dest> {
pub fn validate_transform_into(
self,
ctx: &<Dest::Src as Validate>::Context,
parent: &mut dyn FnMut() -> garde::Path,
report: &mut garde::Report,
) -> Option<Dest> {
Dest::validate_transform_into(self.src, ctx, parent, report)
}
pub fn validate_with(
self,
ctx: &<Dest::Src as Validate>::Context,
) -> Result<Dest, garde::Report> {
let mut report = garde::Report::new();
let result =
Dest::validate_transform_into(self.src, ctx, &mut garde::Path::empty, &mut report);
match report.is_empty() {
true => Ok(result.unwrap()),
false => Err(report),
}
}
}
impl ValidateTransform for AccountSelector {
type Src = UnvalidatedAccountSelector;
fn schema_name() -> Cow<'static, str> {
Cow::Borrowed("AccountSelector")
}
fn validate_transform_into(
src: Self::Src,
ctx: &<Self::Src as Validate>::Context,
mut parent: &mut dyn FnMut() -> garde::Path,
report: &mut garde::Report,
) -> Option<Self> {
let count = report.iter().count();
match src {
UnvalidatedAccountSelector::Username(name) => {
name.validate_into(ctx, parent, report);
if count != report.iter().count() {
return None;
}
Some(AccountSelector {
user: NameOrUuid::Name(name),
account: None,
})
}
UnvalidatedAccountSelector::Object { user, account } => {
{
let mut path = garde::util::nested_path!(parent, "user");
user.validate_into(ctx, &mut path, report);
}
let mut path = garde::util::nested_path!(parent, "account");
account.validate_into(ctx, &mut path, report);
if count != report.iter().count() {
return None;
}
Some(AccountSelector { user, account })
}
}
}
}
#[derive(Debug, Deserialize, PartialEq)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct MakePayment {
from: NameOrUuid,
to: UnvalidatedTransform<AccountTarget>,
amount: u64,
}
impl MakePayment {
pub fn validate(self) -> Result<(NameOrUuid, AccountTarget, u64), garde::Report> {
let mut report = garde::Report::new();
self.from
.validate_into(&(), &mut || garde::Path::new("from"), &mut report);
let to = self
.to
.validate_transform_into(&(), &mut || garde::Path::new("to"), &mut report);
if let Err(error) = garde::rules::range::apply(&self.amount, (Some(1), None)) {
report.append(garde::Path::new("amount"), error);
}
if !report.is_empty() {
return Err(report);
}
Ok((self.from, to.unwrap(), self.amount))
}
}
const TARGET_NOT_FOUND: ApiError<'static> = ApiError::const_new(
StatusCode::NOT_FOUND,
"transaction.target.not_found",
"Not Found",
);
pub async fn make_payment(
EState(state): State,
auth: Auth,
Json(body): Json<MakePayment>,
) -> Result<Json<Transaction>, Error> {
let (from, to, amount) = body.validate()?;
let user_id = auth.user_id();
let mut client = state.conn().await?;
let mut client = client.transaction().await?;
let Some(from) = (match from {
NameOrUuid::Id(uuid) => Accounts::by_id(&client, uuid).await?,
NameOrUuid::Name(name) => Accounts::get_for_user(&client, user_id, &*name).await?,
}) else {
return Err(ApiError::const_new(
StatusCode::NOT_FOUND,
"transaction.from.not_found",
"Not Found",
)
.into());
};
let transaction = match to {
AccountTarget::Interop(user) => {
let Some(interop) = &state.interop else {
return Err(TARGET_NOT_FOUND.into());
};
let builder = TransactionBuilder::new(&mut client, amount).await?;
let transaction = builder.interop_pay(from.id, user.clone(), None).await?;
if amount % 100 != 0 {
todo!()
}
if let Err(err) =
send_interop_payment(&interop, &from.name, &user, (amount / 100) as u32).await
{
return Err(match err {
InteropError::NotFound => TARGET_NOT_FOUND.into(),
InteropError::Http(err) => {
error!("{err}");
ApiError::INTERNAL_SERVER_ERROR.into()
}
InteropError::Other(response) => {
error!("{response:?}");
ApiError::INTERNAL_SERVER_ERROR.into()
}
});
}
transaction
}
AccountTarget::Selector(selector) => {
let Some((to_user, to)) = selector.account_id(&client).await? else {
return Err(TARGET_NOT_FOUND.into());
};
if from.balance < amount {
return Err(ApiError::const_new(
StatusCode::BAD_REQUEST,
"transaction.insufficient_funds",
"Insufficient funds",
)
.into());
}
let builder = TransactionBuilder::new(&mut client, amount).await?;
let (transaction, notification) = builder.normal(from.id, to, None).await?;
client.commit().await?;
state.sockets.send(to_user, notification).await;
transaction
}
};
Ok(Json(transaction))
}
pub struct TransactionBuilder<'a, T> {
client: &'a mut T,
update_balance: Statement,
amount: u64,
}
impl<'a, T: GenericClient> TransactionBuilder<'a, T> {
pub async fn new(client: &'a mut T, amount: u64) -> Result<Self, tokio_postgres::Error> {
let update_balance = client
.prepare_cached("update accounts set balance = balance + $2 where id = $1")
.await?;
Ok(Self {
client,
update_balance,
amount,
})
}
async fn update_balance(
&mut self,
account: Uuid,
is_to: bool,
) -> Result<(), tokio_postgres::Error> {
let amount = if is_to {
self.amount as i64
} else {
-(self.amount as i64)
};
self.client
.execute(&self.update_balance, &[&account, &amount])
.await?;
Ok(())
}
pub async fn system(
mut self,
to: Uuid,
message: Option<String>,
) -> Result<(Transaction, SocketMessage), tokio_postgres::Error> {
self.update_balance(to, true).await?;
let transaction =
Transactions::create(self.client, None, Some(to), None, self.amount, message).await?;
Ok((
transaction,
SocketMessage::Event(SocketEvent::PaymentReceived {
from: None,
to,
amount: self.amount,
}),
))
}
pub async fn normal(
mut self,
from: Uuid,
to: Uuid,
message: Option<String>,
) -> Result<(Transaction, SocketMessage), tokio_postgres::Error> {
self.update_balance(from, false).await?;
self.update_balance(to, true).await?;
let transaction = Transactions::create(
self.client,
Some(from),
Some(to),
None,
self.amount,
message,
)
.await?;
Ok((
transaction,
SocketMessage::Event(SocketEvent::PaymentReceived {
from: Some(NameOrUuid::Id(from)),
to,
amount: self.amount,
}),
))
}
pub async fn interop_pay(
mut self,
from: Uuid,
name: String,
message: Option<String>,
) -> Result<Transaction, tokio_postgres::Error> {
self.update_balance(from, false).await?;
let transaction = Transactions::create(
self.client,
Some(from),
None,
Some(name),
self.amount,
message,
)
.await?;
Ok(transaction)
}
pub async fn interop_receive(
mut self,
to: Uuid,
name: String,
message: Option<String>,
) -> Result<(Transaction, SocketMessage), tokio_postgres::Error> {
self.update_balance(to, true).await?;
let transaction = Transactions::create(
self.client,
None,
Some(to),
Some(name.clone()),
self.amount,
message,
)
.await?;
Ok((
transaction,
SocketMessage::Event(SocketEvent::PaymentReceived {
from: Some(NameOrUuid::Name(Name(name))),
to,
amount: self.amount,
}),
))
}
}
enum InteropError {
NotFound,
Http(reqwest::Error),
Other(reqwest::Response),
}
async fn send_interop_payment(
interop: &InteropState,
from: &str,
to: &str,
amount: u32,
) -> Result<(), InteropError> {
let response = interop
.client
.post(interop.pay_url.clone())
.json(&serde_json::json!({
"from": from,
"to": to,
"amount": amount
}))
.send()
.await
.map_err(InteropError::Http)?;
if response.status() == StatusCode::OK {
return Ok(());
}
if response.status() == StatusCode::NOT_FOUND {
return Err(InteropError::NotFound);
}
Err(InteropError::Other(response))
}
#[cfg(test)]
mod tests {
use bank_core::Name;
use crate::api::transactions::{
AccountSelector, AccountTarget, NameOrUuid, UnvalidatedAccountSelector,
UnvalidatedAccountTarget, UnvalidatedTransform,
};
use super::MakePayment;
#[test]
fn payment_body() {
let uuid = uuid::uuid!("6fd8b7ab-7278-45b5-9916-2b09b4224a38");
let payment = serde_json::from_str::<MakePayment>(
r#"{"from":"personal", "to": { "user": "6fd8b7ab-7278-45b5-9916-2b09b4224a38" }, "amount": 100}"#,
)
.unwrap();
assert_eq!(
MakePayment {
from: NameOrUuid::Name(Name("personal".into())),
to: UnvalidatedTransform {
src: UnvalidatedAccountTarget::Selector(UnvalidatedAccountSelector::Object {
user: NameOrUuid::Id(uuid),
account: None
})
},
amount: 100
},
payment
);
assert_eq!(
payment.validate().unwrap(),
(
NameOrUuid::Name(Name("personal".into())),
AccountTarget::Selector(AccountSelector {
user: NameOrUuid::Id(uuid),
account: None
}),
100
)
);
let payment = serde_json::from_str::<MakePayment>(
r#"{"from":"personal", "to": { "user": "test", "account": "abc" }, "amount": 100}"#,
)
.unwrap();
assert_eq!(
MakePayment {
from: NameOrUuid::Name(Name("personal".into())),
to: UnvalidatedTransform {
src: UnvalidatedAccountTarget::Selector(UnvalidatedAccountSelector::Object {
user: NameOrUuid::Name(Name("test".into())),
account: Some(Name("abc".into()))
})
},
amount: 100
},
payment
);
assert_eq!(
payment.validate().unwrap(),
(
NameOrUuid::Name(Name("personal".into())),
AccountTarget::Selector(AccountSelector {
user: NameOrUuid::Name(Name("test".into())),
account: Some(Name("abc".into()))
}),
100
)
);
}
}