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