bankserver_rust/src/api/transactions.rs
2025-03-11 12:15:26 +01:00

467 lines
14 KiB
Rust

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<Arc<AppState>> {
Router::new().route("/", post(make_payment))
}
make_schemas!((MakePayment); (Pagination<FullTransaction>));
impl PaginationType for FullTransaction {
const NAME: &str = "Transactions";
}
#[derive(Serialize)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct TransactionHistory {
transactions: Vec<Transaction>,
}
#[derive(Deserialize, Validate)]
#[serde(untagged, rename = "PaymentTarget")]
#[garde(context(RefCell<Vec<UserTarget<'static>>> 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::<Self>())
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
let mut uuid_schema = generator.subschema_for::<Uuid>();
let mut name_schema = generator.subschema_for::<Name>();
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<Vec<UserTarget<'static>>>,
) -> 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<UserTarget, garde::Error> {
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<Vec<UserTarget<'static>>>))]
#[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<Name>,
}
impl AccountSelector {
#[instrument(skip(client))]
async fn account_id(
&self,
client: &impl GenericClient,
) -> Result<Option<Uuid>, 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<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)?,
})
}
}
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<AccountSelector>,
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<Direction>,
}
pub async fn transaction_history(
EState(state): State,
auth: Auth,
Query(TransactionQuery {
direction,
pagination,
}): Query<TransactionQuery>,
) -> Result<Json<Pagination<FullTransaction>>, 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<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) => 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::<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: 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::<MakePayment>(
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
)
);
}
}