initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
18
Cargo.toml
Normal file
18
Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "intrasys"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["json"] }
|
||||
tokio = { version = "1.46", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio", "chrono", "bigdecimal", "derive"] }
|
||||
thiserror = "2.0"
|
||||
uuid = { version = "1.17", features = ["v4"] }
|
||||
# validator = { version = "0.20", features = ["derive"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
dotenv = "0.15"
|
||||
bigdecimal = { version = "0.4", features = ["serde"] }
|
16
migrations/20250704000000_init.sql
Normal file
16
migrations/20250704000000_init.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Create accounts table
|
||||
CREATE TABLE accounts (
|
||||
account_id BIGINT PRIMARY KEY,
|
||||
balance DECIMAL(20, 10) NOT NULL
|
||||
);
|
||||
|
||||
-- Create transactions table for audit purposes
|
||||
CREATE TABLE transactions (
|
||||
transaction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source_account_id BIGINT NOT NULL,
|
||||
destination_account_id BIGINT NOT NULL,
|
||||
amount DECIMAL(20, 10) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
FOREIGN KEY (source_account_id) REFERENCES accounts (account_id),
|
||||
FOREIGN KEY (destination_account_id) REFERENCES accounts (account_id)
|
||||
);
|
19
src/db.rs
Executable file
19
src/db.rs
Executable file
@@ -0,0 +1,19 @@
|
||||
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
use tracing::info;
|
||||
|
||||
pub type DbPool = PgPool;
|
||||
pub type DbTransaction<'a> = Transaction<'a, Postgres>;
|
||||
|
||||
pub async fn create_pool() -> Result<DbPool, sqlx::Error> {
|
||||
info!("Creating database connection pool...");
|
||||
PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(PgConnectOptions::new())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn run_migrations(pool: &DbPool) -> Result<(), sqlx::migrate::MigrateError> {
|
||||
info!("Running database migrations...");
|
||||
sqlx::migrate!("./migrations").run(pool).await
|
||||
}
|
34
src/errors.rs
Normal file
34
src/errors.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use sqlx::Error as SqlxError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AppError {
|
||||
#[error("Account not found: {0}")]
|
||||
AccountNotFound(i64),
|
||||
#[error("Account already exists")]
|
||||
AccountAlreadyExists,
|
||||
#[error("Insufficient funds")]
|
||||
InsufficientFunds,
|
||||
#[error("Database error: {0}")]
|
||||
DatabaseError(#[from] SqlxError),
|
||||
#[error("Invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match self {
|
||||
AppError::AccountNotFound(_) => StatusCode::NOT_FOUND,
|
||||
AppError::InsufficientFunds => StatusCode::BAD_REQUEST,
|
||||
AppError::AccountAlreadyExists => StatusCode::BAD_REQUEST,
|
||||
AppError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AppError::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
||||
};
|
||||
|
||||
(status, self.to_string()).into_response()
|
||||
}
|
||||
}
|
167
src/handlers.rs
Normal file
167
src/handlers.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use bigdecimal::BigDecimal;
|
||||
use sqlx::types::BigDecimal as SqlxBigDecimal;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
db::DbPool,
|
||||
errors::AppError,
|
||||
models::{Account, CreateAccount, TransactionRequest},
|
||||
};
|
||||
|
||||
pub async fn create_account(
|
||||
State(pool): State<DbPool>,
|
||||
Json(payload): Json<CreateAccount>,
|
||||
) -> Result<(), AppError> {
|
||||
if payload.account_id < 1 {
|
||||
return Err(AppError::InvalidInput(String::from(
|
||||
"account_id is invalid",
|
||||
)));
|
||||
}
|
||||
|
||||
if payload.initial_balance < BigDecimal::from(0) {
|
||||
return Err(AppError::InvalidInput(String::from(
|
||||
"initial_balance is negative",
|
||||
)));
|
||||
}
|
||||
info!("Creating account: {:?}", payload.account_id);
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO accounts (account_id, balance)
|
||||
VALUES ($1, $2)
|
||||
"#,
|
||||
)
|
||||
.bind(payload.account_id)
|
||||
.bind(SqlxBigDecimal::from(payload.initial_balance))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
sqlx::Error::Database(_) => AppError::AccountAlreadyExists,
|
||||
_ => AppError::from(err),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_account(
|
||||
State(pool): State<DbPool>,
|
||||
Path(account_id): Path<i64>,
|
||||
) -> Result<Json<Account>, AppError> {
|
||||
if account_id < 1 {
|
||||
return Err(AppError::InvalidInput(String::from(
|
||||
"account_id is invalid",
|
||||
)));
|
||||
}
|
||||
info!("Fetching account: {}", account_id);
|
||||
|
||||
let account = sqlx::query_as::<_, Account>(
|
||||
r#"
|
||||
SELECT account_id, balance
|
||||
FROM accounts
|
||||
WHERE account_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| AppError::AccountNotFound(account_id))?;
|
||||
|
||||
Ok(Json(account))
|
||||
}
|
||||
|
||||
pub async fn process_transaction(
|
||||
State(pool): State<DbPool>,
|
||||
Json(payload): Json<TransactionRequest>,
|
||||
) -> Result<(), AppError> {
|
||||
if payload.source_account_id < 1 {
|
||||
return Err(AppError::InvalidInput(String::from(
|
||||
"source_account_id is invalid",
|
||||
)));
|
||||
}
|
||||
|
||||
if payload.destination_account_id < 1 {
|
||||
return Err(AppError::InvalidInput(String::from(
|
||||
"destination_account_id is invalid",
|
||||
)));
|
||||
}
|
||||
|
||||
if payload.amount < BigDecimal::from_str("0.01").unwrap() {
|
||||
return Err(AppError::InvalidInput(String::from(
|
||||
"Minimum amount is 0.01",
|
||||
)));
|
||||
}
|
||||
info!(
|
||||
"Processing transaction from {} to {} for amount {}",
|
||||
payload.source_account_id, payload.destination_account_id, payload.amount
|
||||
);
|
||||
|
||||
let mut tx = pool.begin().await.map_err(AppError::from)?;
|
||||
|
||||
// Check source account balance
|
||||
let source_balance: SqlxBigDecimal = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT balance FROM accounts WHERE account_id = $1 FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(payload.source_account_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|_| AppError::AccountNotFound(payload.source_account_id))?;
|
||||
|
||||
if source_balance < SqlxBigDecimal::from(payload.amount.clone()) {
|
||||
return Err(AppError::InsufficientFunds);
|
||||
}
|
||||
|
||||
// Update source account
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE accounts
|
||||
SET balance = balance - $1
|
||||
WHERE account_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(SqlxBigDecimal::from(payload.amount.clone()))
|
||||
.bind(payload.source_account_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
// Update destination account
|
||||
let update_result = sqlx::query(
|
||||
r#"
|
||||
UPDATE accounts
|
||||
SET balance = balance + $1
|
||||
WHERE account_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(SqlxBigDecimal::from(payload.amount.clone()))
|
||||
.bind(payload.destination_account_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
if update_result.rows_affected() == 0 {
|
||||
return Err(AppError::AccountNotFound(payload.destination_account_id));
|
||||
}
|
||||
|
||||
// Record transaction
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO transactions (source_account_id, destination_account_id, amount)
|
||||
VALUES ($1, $2, $3)
|
||||
"#,
|
||||
)
|
||||
.bind(payload.source_account_id)
|
||||
.bind(payload.destination_account_id)
|
||||
.bind(SqlxBigDecimal::from(payload.amount.clone()))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
tx.commit().await.map_err(AppError::from)?;
|
||||
|
||||
Ok(())
|
||||
}
|
41
src/main.rs
Normal file
41
src/main.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
use dotenv::dotenv;
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::{FmtSubscriber, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
mod db;
|
||||
mod errors;
|
||||
mod handlers;
|
||||
mod models;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let subscriber = FmtSubscriber::builder()
|
||||
.with_max_level(Level::TRACE)
|
||||
.finish();
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.expect("Setting default tracing subscriber failed");
|
||||
dotenv().ok();
|
||||
|
||||
let pool = db::create_pool().await.expect("Failed to create pool");
|
||||
|
||||
// Run migrations
|
||||
db::run_migrations(&pool)
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
|
||||
// Build our application's routes
|
||||
let app = Router::new()
|
||||
.route("/accounts", post(handlers::create_account))
|
||||
.route("/accounts/{account_id}", get(handlers::get_account))
|
||||
.route("/transactions", post(handlers::process_transaction))
|
||||
.with_state(pool);
|
||||
|
||||
// Run our app
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
tracing::debug!("listening on {}", listener.local_addr().unwrap());
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
62
src/models.rs
Normal file
62
src/models.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use bigdecimal::{BigDecimal, ParseBigDecimalError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Database, Decode, Encode, FromRow, Type, error::BoxDynError};
|
||||
use std::cmp::PartialOrd;
|
||||
use std::error::Error;
|
||||
use std::fmt::Display;
|
||||
// use validator::{Validate, ValidateRange};
|
||||
|
||||
// #[derive(Debug, Serialize, Deserialize, PartialEq, PartialOrd, Clone, Type)]
|
||||
// #[sqlx(transparent)]
|
||||
// pub struct Amount(BigDecimal);
|
||||
|
||||
// impl Amount {
|
||||
// fn new(text: &str) -> Result<Amount, ParseBigDecimalError> {
|
||||
// Ok(Amount(BigDecimal::from_str(text)?))
|
||||
// }
|
||||
// }
|
||||
|
||||
// impl Display for Amount {
|
||||
// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// self.0.fmt(f)
|
||||
// }
|
||||
// }
|
||||
|
||||
// impl ValidateRange<Amount> for Amount {
|
||||
// fn greater_than(&self, max: Amount) -> Option<bool> {
|
||||
// Some(self > &max)
|
||||
// }
|
||||
|
||||
// fn less_than(&self, min: Amount) -> Option<bool> {
|
||||
// Some(self < &min)
|
||||
// }
|
||||
// }
|
||||
|
||||
// impl From<Amount> for BigDecimal {
|
||||
// fn from(value: Amount) -> Self {
|
||||
// value.0
|
||||
// }
|
||||
// }
|
||||
|
||||
type Amount = BigDecimal;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct Account {
|
||||
pub account_id: i64,
|
||||
pub balance: Amount,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateAccount {
|
||||
pub account_id: i64,
|
||||
pub initial_balance: Amount,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TransactionRequest {
|
||||
pub source_account_id: i64,
|
||||
pub destination_account_id: i64,
|
||||
pub amount: Amount,
|
||||
}
|
Reference in New Issue
Block a user