use std::{borrow::Cow, cell::RefCell, sync::Arc}; use axum::{Router, routing::post}; use deadpool_postgres::GenericClient; use garde::Validate; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tracing::instrument; use uuid::Uuid; use crate::model::{ Account, Direction, FullTransaction, Name, Transaction, USER_ACCOUNT_PATTERN, User, }; use super::{ AppState, EState, Error, Json, Pagination, PaginationType, Query, RequestPagination, State, auth::Auth, make_schemas, }; pub(super) fn router() -> Router> { Router::new().route("/", post(make_payment)) } make_schemas!((MakePayment); (Pagination)); impl PaginationType for FullTransaction { const NAME: &str = "Transactions"; } #[derive(Serialize)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] pub struct TransactionHistory { transactions: Vec, } #[derive(Deserialize, Validate)] #[serde(untagged, rename = "PaymentTarget")] #[garde(context(RefCell>> as ctx))] pub enum BodyPaymentTarget { Id(#[garde(skip)] Uuid), Text(#[garde(custom(validate_user_target))] String), } impl schemars::JsonSchema for BodyPaymentTarget { fn schema_name() -> Cow<'static, str> { Cow::Borrowed("PaymentTarget") } fn schema_id() -> Cow<'static, str> { Cow::Borrowed(std::any::type_name::()) } fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { let mut uuid_schema = generator.subschema_for::(); let mut name_schema = generator.subschema_for::(); uuid_schema.insert("description".to_owned(), "Account Id".into()); name_schema.insert("description".to_owned(), "Username".into()); schemars::Schema::try_from(serde_json::json!({ "oneOf": [ uuid_schema, name_schema, { "type": "string", "minLength": 7, "maxLength": 65, "pattern": USER_ACCOUNT_PATTERN, "description": "User and Account seperated with ':'" } ] })) .unwrap() } } fn validate_user_target( value: &str, ctx: &RefCell>>, ) -> Result<(), garde::Error> { let parsed = parse_target(value)?; ctx.borrow_mut().push(match parsed { UserTarget::User(name) => UserTarget::User(name.into_owned().into()), UserTarget::UserAccount(name, account) => { UserTarget::UserAccount(name.into_owned().into(), account.into_owned().into()) } }); Ok(()) } fn parse_target(target: &str) -> Result { let mut target = target.splitn(2, ':'); let user = target.next().unwrap(); let Some(account) = target.next() else { Name::validate_name(user)?; return Ok(UserTarget::User(Cow::Borrowed(user))); }; Name::validate_name(account)?; Ok(UserTarget::UserAccount( Cow::Borrowed(user), Cow::Borrowed(account), )) } pub enum UserTarget<'a> { User(Cow<'a, str>), UserAccount(Cow<'a, str>, Cow<'a, str>), } #[derive(Deserialize, Validate)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] #[garde(context(RefCell>>))] #[serde(rename = "MakePayment")] pub struct BodyMakePayment { #[garde(dive)] pub from: BodyPaymentTarget, #[garde(dive)] pub to: BodyPaymentTarget, /// amount in cents #[garde(range(min = 1))] pub amount: u64, } #[derive(Debug, Deserialize, Validate, PartialEq)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] #[serde(untagged)] pub enum NameOrUuid { Id(#[garde(skip)] Uuid), Name(#[garde(dive)] Name), } #[derive(Debug, Validate, PartialEq)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] pub struct AccountSelector { #[garde(dive)] user: NameOrUuid, #[garde(dive)] account: Option, } impl AccountSelector { #[instrument(skip(client))] async fn account_id( &self, client: &impl GenericClient, ) -> Result, tokio_postgres::Error> { let user_id = match &self.user { NameOrUuid::Id(uuid) => *uuid, NameOrUuid::Name(name) => match User::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 Account::get_for_user(client, user_id, &*name).await? { Some(info) => info.id, None => return Ok(None), }, None => user_id, }; Ok(Some(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, }, } impl<'de> Deserialize<'de> for AccountSelector { fn deserialize(deserializer: D) -> Result 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::()) } fn validate_transform_into( src: Self::Src, ctx: &::Context, parent: &mut dyn FnMut() -> garde::Path, report: &mut garde::Report, ) -> Option; } #[derive(Debug, PartialEq)] pub struct UnvalidatedTransform { src: Dest::Src, } impl<'de, Dest> Deserialize<'de> for UnvalidatedTransform where Dest: ValidateTransform, Dest::Src: Deserialize<'de>, { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { Ok(UnvalidatedTransform { src: >::deserialize(deserializer)?, }) } } impl schemars::JsonSchema for UnvalidatedTransform 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 { ::json_schema(generator) } fn always_inline_schema() -> bool { ::always_inline_schema() } } impl UnvalidatedTransform { pub fn validate_transform_into( self, ctx: &::Context, parent: &mut dyn FnMut() -> garde::Path, report: &mut garde::Report, ) -> Option { Dest::validate_transform_into(self.src, ctx, parent, report) } pub fn validate_with( self, ctx: &::Context, ) -> Result { 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: &::Context, mut parent: &mut dyn FnMut() -> garde::Path, report: &mut garde::Report, ) -> Option { 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, amount: u64, } impl MakePayment { pub fn validate(self) -> Result<(NameOrUuid, AccountSelector, 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)) } } #[derive(Debug, Deserialize)] pub struct TransactionQuery { #[serde(flatten)] pub pagination: RequestPagination, pub direction: Option, } pub async fn transaction_history( EState(state): State, auth: Auth, Query(TransactionQuery { direction, pagination, }): Query, ) -> Result>, Error> { let client = state.conn().await?; let result = Transaction::user_history(&client, auth.user_id(), direction, pagination).await?; Ok(Json(result)) } pub async fn make_payment( EState(state): State, auth: Auth, Json(body): Json, ) -> Result, 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) => Account::by_id(&client, uuid).await?, NameOrUuid::Name(name) => Account::get_for_user(&client, user_id, &*name).await?, }) else { todo!("from account doesn't exist") }; let Some(to) = to.account_id(&client).await? else { todo!("to account doesn't exist") }; if from.balance < amount { todo!("not enough money") } let update_balance_stmt = client .prepare_cached("update accounts set balance = balance + $2 where id = $1") .await?; client .execute(&update_balance_stmt, &[&from.id, &(-(amount as i64))]) .await?; client .execute(&update_balance_stmt, &[&to, &(amount as i64)]) .await?; let transaction = Transaction::create(&mut client, from.id, to, amount, None).await?; client.commit().await?; Ok(Json(transaction)) } #[cfg(test)] mod tests { use crate::{ api::transactions::{ AccountSelector, NameOrUuid, UnvalidatedAccountSelector, UnvalidatedTransform, }, model::Name, }; use super::MakePayment; #[test] fn payment_body() { let uuid = uuid::uuid!("6fd8b7ab-7278-45b5-9916-2b09b4224a38"); let payment = serde_json::from_str::( 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: UnvalidatedAccountSelector::Object { user: NameOrUuid::Id(uuid), account: None } }, amount: 100 }, payment ); assert_eq!( payment.validate().unwrap(), ( NameOrUuid::Name(Name("personal".into())), AccountSelector { user: NameOrUuid::Id(uuid), account: None }, 100 ) ); let payment = serde_json::from_str::( r#"{"from":"personal", "to": { "user": "test", "account": "abc" }, "amount": 100}"#, ) .unwrap(); assert_eq!( MakePayment { from: NameOrUuid::Name(Name("personal".into())), to: UnvalidatedTransform { src: 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())), AccountSelector { user: NameOrUuid::Name(Name("test".into())), account: Some(Name("abc".into())) }, 100 ) ); } }