mirror of
https://git.dirksys.ovh/dirk/bankserver.git
synced 2025-12-20 11:09:21 +01:00
467 lines
14 KiB
Rust
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
|
|
)
|
|
);
|
|
}
|
|
}
|