initial commit
Some checks failed
CI / build (push) Failing after 3s

This commit is contained in:
2025-07-04 11:56:23 +08:00
commit 59c0401512
17 changed files with 3337 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.git
target
README.md
Dockerfile
docker-compose.yml

9
.env Normal file
View File

@@ -0,0 +1,9 @@
INTRASYS_HOST=0.0.0.0
INTRASYS_PORT=8080
PGUSER=postgres
PGHOST=127.0.0.1
PGPASSWORD=password
INTRASYS_LOG=trace
#Only use for tests
DATABASE_URL=postgres://postgres:password@localhost:5432

View File

@@ -0,0 +1,26 @@
name: CI
on:
push:
branches: [ master ]
jobs:
build:
runs-on: woryzen
steps:
- name: Login to Gitea container registry
uses: docker/login-action@v3
with:
registry: gitea.woggioni.net
username: woggioni
password: ${{ secrets.PUBLISHER_TOKEN }}
-
name: Build rbcs Docker image
uses: docker/build-push-action@v5.3.0
with:
platforms: |
linux/amd64
linux/arm64
push: true
pull: true
tags: |
gitea.woggioni.net/woggioni/intrasys:latest
cache-from: type=registry,ref=gitea.woggioni.net/woggioni/intrasys:buildx

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

2612
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
Cargo.toml Normal file
View File

@@ -0,0 +1,23 @@
[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"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenv = "0.15"
bigdecimal = { version = "0.4", features = ["serde"] }
[lib]
crate-type = ["bin"]
[dev-dependencies]
axum-test = "17.3"

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM --platform=$BUILDPLATFORM alpine:latest AS base
ARG TARGETPLATFORM
FROM base AS builder-linux_amd64
ENV RUST_TARGET="x86_64-unknown-linux-musl"
ENV CC_x86_64-unknown-linux-musl=/opt/x-tools/x86_64-unknown-linux-musl/bin/x86_64-unknown-linux-musl-gcc
ENV CXX_x86_64-unknown-linux-musl=/opt/x-tools/x86_64-unknown-linux-musl/bin/x86_64-unknown-linux-musl-g++
FROM base AS builder-linux_arm64
ENV RUST_TARGET="aarch64-unknown-linux-musl"
ENV CC_aarch64_unknown_linux_musl=/opt/x-tools/aarch64-unknown-linux-musl/bin/aarch64-unknown-linux-musl-gcc
ENV CXX_aarch64_unknown_linux_musl=/opt/x-tools/aarch64-unknown-linux-musl/bin/aarch64-unknown-linux-musl-g++
FROM builder-${TARGETPLATFORM/\//_} AS build
RUN --mount=type=cache,target=/var/cache/apk apk update
RUN --mount=type=cache,target=/var/cache/apk apk add rustup binutils gcc musl-dev linux-headers
RUN adduser -D luser
USER luser
WORKDIR /home/luser
RUN rustup-init -y --profile minimal --target "$RUST_TARGET"
ADD --chown=luser:users . .
RUN source $HOME/.cargo/env && cargo build --release --target "$RUST_TARGET"
RUN cp "target/${RUST_TARGET}/release/intrasys" "./intrasys.x"
FROM scratch AS release
COPY --from=build --chown=9999:9999 /home/luser/intrasys.x /usr/local/bin/intrasys
USER 9999
ENTRYPOINT ["/usr/local/bin/intrasys"]
ENV INTRASYS_HOST="0.0.0.0"
EXPOSE 8080/tcp

9
LICENSE Normal file
View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2025 woggioni
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

118
README.md Normal file
View File

@@ -0,0 +1,118 @@
# Intrasys - Internal Transfers System
![Rust](https://img.shields.io/badge/rust-%23000000.svg?logo=rust&logoColor=white)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?style=for-square&logo=postgresql&logoColor=white)
![Tokio](https://img.shields.io/badge/Tokio-1.46-green?style=for-square)
![Sqlx](https://img.shields.io/badge/Sqlx-0.8-green?style=for-square)
![Axum](https://img.shields.io/badge/Axum-0.8-green?style=for-square)
Intrasys is an internal financial transfers application built with Rust and Axum, providing HTTP endpoints for account management and transaction processing with PostgreSQL as the backing database.
## Features
- Account creation with initial balance
- Account balance queries
- Secure transaction processing between accounts
- Atomic transaction handling with database integrity
- Precise decimal arithmetic for financial amounts
## Prerequisites
- Rust (latest stable version)
- Docker (for database provisioning)
- PostgreSQL client (optional)
## Quick Start
### Using docker compose
Just run
```bash
docker compose up
```
### Build locally
#### Ensure you have rust installed
Follow the steps described [here](https://www.rust-lang.org/tools/install) if you don't
#### Provision PostgreSQL Database
The easiest way is to run a PostgreSQL container with Docker:
```bash
docker run --name intrasys-pg -d -e POSTGRES_PASSWORD=password -p 127.0.0.1:5432:5432 postgres:alpine
```
#### Run the Application
```bash
cargo run
```
The application will be available at `http://localhost:8080`
## API Endpoints
### Account Management
- **Create Account**
`POST /accounts`
```json
{
"account_id": 123,
"initial_balance": "100.23344"
}
```
- **Get Account Balance**
`GET /accounts/{account_id}`
Response:
```json
{
"account_id": 123,
"balance": "100.23344"
}
```
### Transactions
- **Process Transaction**
`POST /transactions`
```json
{
"source_account_id": 123,
"destination_account_id": 456,
"amount": "50.12345"
}
```
## Configuration
The app can be configured using environmental variable that can also be provided through a `.dotenv` file.
The following variables affect the application behavior:
- `INTRASYS_LOG` changes the logging level, can be set to one of `trace`, `debug`, `info`, `warn`, `error`
- `INTRASYS_HOST` is the address the HTTP server will bind to
- `INTRASYS_PORT` is the port the HTTP server will listen to
- any of the environmental variables mentioned [here](https://docs.rs/sqlx/latest/sqlx/postgres/struct.PgConnectOptions.html#method.options) affects the postgres database connection
## Running tests
Make sure you have an available postgres database and edit `DATABASE_URL` variable in the `.env` file accordingly. If you've used the docker command mentioned in the *Quickstart*
section you don't need to change anything.
Then run
```bash
cargo test
```
## Assumptions
- All accounts use the same currency
- No authentication or authorization is implemented
- Decimal precision of 2 decimal places is sufficient for financial amounts, maximum allowed monetary amount is (10^18 -0.01) and the minimum is (-10^18 + 0.01)
- Account IDs are positive integers
- Account creation must NOT be idempotent since it also sets the account balance (creating an account that already exists should fail)
- Transfer money to an account that does not exist should not create a new account, but fail with an error (to prevent users from accidentally locking their money forever)

40
docker-compose.yml Normal file
View File

@@ -0,0 +1,40 @@
volumes:
postgres-data:
driver: local
services:
postgres:
image: postgres:alpine
restart: unless-stopped
environment:
- POSTGRES_PASSWORD=password
volumes:
- postgres-data:/var/lib/postgresql/data
deploy:
resources:
limits:
cpus: 1.00
memory: 256M
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 30s
timeout: 60s
retries: 5
start_period: 5s
intrasys:
image: gitea.woggioni.net/woggioni/intrasys:latest
environment:
- PGHOST=postgres
- PGUSER=postgres
- PGPASSWORD=password
- INTRASYS_LOG=trace
ports:
- "127.0.0.1:8080:8080"
deploy:
resources:
limits:
cpus: 1.00
memory: 32M
depends_on:
postgres:
condition: service_healthy

View File

@@ -0,0 +1,16 @@
-- Create accounts table
CREATE TABLE accounts (
account_id BIGINT PRIMARY KEY,
balance DECIMAL(20, 2) 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, 2) 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)
);

18
src/db.rs Executable file
View File

@@ -0,0 +1,18 @@
use sqlx::PgPool;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use tracing::info;
pub(crate) type DbPool = PgPool;
pub(crate) async fn create_pool() -> Result<DbPool, sqlx::Error> {
info!("Creating database connection pool...");
PgPoolOptions::new()
.max_connections(5)
.connect_with(PgConnectOptions::new())
.await
}
pub(crate) 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
View File

@@ -0,0 +1,34 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use sqlx::Error as SqlxError;
use thiserror::Error;
#[derive(Error, Debug)]
pub(crate) 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()
}
}

176
src/handlers.rs Normal file
View File

@@ -0,0 +1,176 @@
use std::str::FromStr;
use axum::{
Json, Router,
extract::{Path, State},
routing::{get, post},
};
use bigdecimal::BigDecimal;
use sqlx::types::BigDecimal as SqlxBigDecimal;
use tracing::debug;
use crate::{
db::DbPool,
errors::AppError,
models::{Account, CreateAccount, TransactionRequest},
};
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",
)));
}
debug!("Creating account: {:?}", payload.account_id);
sqlx::query(
r#"
INSERT INTO accounts (account_id, balance)
VALUES ($1, $2)
"#,
)
.bind(payload.account_id)
.bind(payload.initial_balance)
.execute(&pool)
.await
.map_err(|err| match err {
sqlx::Error::Database(_) => AppError::AccountAlreadyExists,
_ => AppError::from(err),
})?;
Ok(())
}
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",
)));
}
debug!("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))
}
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",
)));
}
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 < payload.amount.clone() {
return Err(AppError::InsufficientFunds);
}
// Update source account
sqlx::query(
r#"
UPDATE accounts
SET balance = balance - $1
WHERE account_id = $2
"#,
)
.bind(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(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(payload.amount.clone())
.execute(&mut *tx)
.await
.map_err(AppError::from)?;
debug!(
"Processing transaction from {} to {} for amount {}",
payload.source_account_id, payload.destination_account_id, payload.amount
);
tx.commit().await.map_err(AppError::from)?;
Ok(())
}
pub(crate) fn create_router(pool: DbPool) -> Router {
Router::new()
.route("/accounts", post(create_account))
.route("/accounts/{account_id}", get(get_account))
.route("/transactions", post(process_transaction))
.with_state(pool)
}

45
src/lib.rs Normal file
View File

@@ -0,0 +1,45 @@
use dotenv::dotenv;
use std::env;
use tracing::info;
use tracing_subscriber::layer::SubscriberExt;
mod db;
mod errors;
mod handlers;
mod models;
#[tokio::main(flavor = "current_thread")]
async fn main() {
//Parse .env file and add the environmental variables configured there
dotenv().ok();
let subscriber = tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("INTRASYS_LOG").unwrap_or_else(|_| "info".into()),
))
.with(tracing_subscriber::fmt::layer());
tracing::subscriber::set_global_default(subscriber)
.expect("Failed to set the global tracing subscriber");
let pool = db::create_pool().await.expect("Failed to create pool");
// Run migrations
db::run_migrations(&pool)
.await
.expect("Failed to run migrations");
// Build the application's routes
let app = handlers::create_router(pool);
// Run the server
let host = env::var("INTRASYS_HOST").unwrap_or(String::from("127.0.0.1"));
let port = env::var("INTRASYS_PORT").unwrap_or(String::from("8080"));
let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
.await
.unwrap();
info!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;

24
src/models.rs Normal file
View File

@@ -0,0 +1,24 @@
use bigdecimal::BigDecimal;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
type Amount = BigDecimal;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub(crate) struct Account {
pub account_id: i64,
pub balance: Amount,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct CreateAccount {
pub account_id: i64,
pub initial_balance: Amount,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct TransactionRequest {
pub source_account_id: i64,
pub destination_account_id: i64,
pub amount: Amount,
}

149
src/tests.rs Normal file
View File

@@ -0,0 +1,149 @@
use std::str::FromStr;
use crate::handlers::create_router;
use axum::http::StatusCode;
use axum_test::TestServer;
use bigdecimal::BigDecimal;
use serde_json::{Value, json};
use sqlx::{Pool, Postgres};
#[sqlx::test]
async fn test_get_account(pool: Pool<Postgres>) {
let app = create_router(pool.clone());
let server = TestServer::new(app).unwrap();
let response = server.get("/accounts/3").await;
//Account does not exist yet
assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
// Create test account
sqlx::query("INSERT INTO accounts (account_id, balance) VALUES (3, 150.50)")
.execute(&pool)
.await
.unwrap();
let response = server.get("/accounts/3").await;
assert_eq!(response.status_code(), StatusCode::OK);
let account: Value = response.json();
assert_eq!(3, account["account_id"]);
assert_eq!(
BigDecimal::from_str("150.50"),
BigDecimal::from_str(account["balance"].as_str().unwrap())
);
let response = server.get("/accounts/4").await;
//Account does not exist
assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
}
#[sqlx::test]
async fn test_create_account_success(pool: Pool<Postgres>) {
let app = create_router(pool.clone());
let server = TestServer::new(app).unwrap();
let response = server
.post("/accounts")
.json(&json!({
"account_id": 1,
"initial_balance": "100.00"
}))
.await;
assert_eq!(response.status_code(), StatusCode::OK);
// Verify account was created
let account = sqlx::query_as::<_, (i64, BigDecimal)>(
"SELECT account_id, balance FROM accounts WHERE account_id = 1",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(account.0, 1);
assert_eq!(account.1, BigDecimal::from(100));
}
#[sqlx::test]
async fn test_process_transaction_success(pool: Pool<Postgres>) {
let app = create_router(pool.clone());
let server = TestServer::new(app).unwrap();
// Create test accounts
sqlx::query("INSERT INTO accounts (account_id, balance) VALUES (4, 200.00), (5, 50.00)")
.execute(&pool)
.await
.unwrap();
let response = server
.post("/transactions")
.json(&json!({
"source_account_id": 4,
"destination_account_id": 5,
"amount": "75.25"
}))
.await;
assert_eq!(response.status_code(), StatusCode::OK);
// Verify balances were updated
let source = sqlx::query_as::<_, (i64, BigDecimal)>(
"SELECT account_id, balance FROM accounts WHERE account_id = 4",
)
.fetch_one(&pool)
.await
.unwrap();
let destination = sqlx::query_as::<_, (i64, BigDecimal)>(
"SELECT account_id, balance FROM accounts WHERE account_id = 5",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(source.1, BigDecimal::from_str("124.75").unwrap());
assert_eq!(destination.1, BigDecimal::from_str("125.25").unwrap());
}
#[sqlx::test]
async fn test_process_transaction_insufficient_funds(pool: Pool<Postgres>) {
let app = create_router(pool.clone());
let server = TestServer::new(app).unwrap();
// Create test accounts
sqlx::query("INSERT INTO accounts (account_id, balance) VALUES (6, 50.00), (7, 100.00)")
.execute(&pool)
.await
.unwrap();
let response = server
.post("/transactions")
.json(&json!({
"source_account_id": 6,
"destination_account_id": 7,
"amount": "75.00"
}))
.await;
assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(response.text(), "Insufficient funds");
// Verify balances were not changed
let source = sqlx::query_as::<_, (i64, BigDecimal)>(
"SELECT account_id, balance FROM accounts WHERE account_id = 6",
)
.fetch_one(&pool)
.await
.unwrap();
let destination = sqlx::query_as::<_, (i64, BigDecimal)>(
"SELECT account_id, balance FROM accounts WHERE account_id = 7",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(source.1, BigDecimal::from_str("50.00").unwrap());
assert_eq!(destination.1, BigDecimal::from_str("100.00").unwrap());
}