Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .coverage
Binary file not shown.
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,8 @@ env/

# Additional macOS metadata
.AppleDouble
.LSOverride
.LSOverride

# Database files
*.db
coverage.xml
Empty file added banking.db
Empty file.
271 changes: 271 additions & 0 deletions banking/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@

import aiosqlite
import asyncio
from typing import List
from banking.errors import AccountNotFoundError, InsufficientFundsError, NegativeAmountError

DB_PATH = "banking.db"

class Database:
def __init__(self, db_path=DB_PATH):
self.db_path = db_path
self._connection = None
self._lock = asyncio.Lock()

async def get_db(self):
if self.db_path == ":memory:":
if self._connection is None:
self._connection = await aiosqlite.connect(self.db_path)
return self._connection
return aiosqlite.connect(self.db_path)

async def init_db(self):
if self.db_path == ":memory:":
db = await self.get_db()
await self._init_tables(db)
else:
async with aiosqlite.connect(self.db_path) as db:
await self._init_tables(db)
await db.commit()

async def _init_tables(self, db):
await db.execute("""
CREATE TABLE IF NOT EXISTS accounts (
name TEXT PRIMARY KEY,
balance REAL NOT NULL
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_name TEXT NOT NULL,
action TEXT NOT NULL,
amount REAL NOT NULL,
other_party TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(account_name) REFERENCES accounts(name)
)
""")
await db.commit()

async def reset_db(self):
if self.db_path == ":memory:":
db = await self.get_db()
await db.execute("DROP TABLE IF EXISTS transactions")
await db.execute("DROP TABLE IF EXISTS accounts")
await db.commit()
await self._init_tables(db)
# Re-init lock for the new test context if needed,
# but usually the lock object is reusable if we didn't close it.
# However, if the loop changed (pytest-asyncio), we MUST create a new lock.
self._lock = asyncio.Lock()
else:
async with aiosqlite.connect(self.db_path) as db:
await db.execute("DROP TABLE IF EXISTS transactions")
await db.execute("DROP TABLE IF EXISTS accounts")
await db.commit()
await self._init_tables(db)

async def create_account(self, name: str, initial_balance: float):
if initial_balance < 0:
raise ValueError("Initial balance cannot be negative")

if self.db_path == ":memory:":
db = await self.get_db()
try:
await db.execute("INSERT INTO accounts (name, balance) VALUES (?, ?)", (name, initial_balance))
await db.execute("INSERT INTO transactions (account_name, action, amount) VALUES (?, ?, ?)",
(name, "Account created", initial_balance))
await db.commit()
except aiosqlite.IntegrityError:
raise ValueError("Account already exists.")
else:
async with aiosqlite.connect(self.db_path) as db:
try:
await db.execute("INSERT INTO accounts (name, balance) VALUES (?, ?)", (name, initial_balance))
await db.execute("INSERT INTO transactions (account_name, action, amount) VALUES (?, ?, ?)",
(name, "Account created", initial_balance))
await db.commit()
except aiosqlite.IntegrityError:
raise ValueError("Account already exists.")

async def get_balance(self, name: str) -> float:
if self.db_path == ":memory:":
db = await self.get_db()
async with db.execute("SELECT balance FROM accounts WHERE name = ?", (name,)) as cursor:
row = await cursor.fetchone()
if row is None:
raise AccountNotFoundError(f"Account '{name}' not found")
return row[0]
else:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute("SELECT balance FROM accounts WHERE name = ?", (name,)) as cursor:
row = await cursor.fetchone()
if row is None:
raise AccountNotFoundError(f"Account '{name}' not found")
return row[0]

async def deposit(self, name: str, amount: float):
if amount <= 0:
raise NegativeAmountError("Amount must be positive")

if self.db_path == ":memory:":
db = await self.get_db()
async with db.execute("SELECT balance FROM accounts WHERE name = ?", (name,)) as cursor:
if await cursor.fetchone() is None:
raise AccountNotFoundError(f"Account '{name}' not found")

await db.execute("UPDATE accounts SET balance = balance + ? WHERE name = ?", (amount, name))
await db.execute("INSERT INTO transactions (account_name, action, amount) VALUES (?, ?, ?)",
(name, "Deposited", amount))
await db.commit()
else:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute("SELECT balance FROM accounts WHERE name = ?", (name,)) as cursor:
if await cursor.fetchone() is None:
raise AccountNotFoundError(f"Account '{name}' not found")

await db.execute("UPDATE accounts SET balance = balance + ? WHERE name = ?", (amount, name))
await db.execute("INSERT INTO transactions (account_name, action, amount) VALUES (?, ?, ?)",
(name, "Deposited", amount))
await db.commit()

async def withdraw(self, name: str, amount: float):
if amount <= 0:
raise NegativeAmountError("Amount must be positive")

if self.db_path == ":memory:":
db = await self.get_db()
async with db.execute("SELECT balance FROM accounts WHERE name = ?", (name,)) as cursor:
row = await cursor.fetchone()
if row is None:
raise AccountNotFoundError(f"Account '{name}' not found")
balance = row[0]

if amount > balance:
raise InsufficientFundsError(f"Cannot withdraw {amount:.2f}; balance is only {balance:.2f}")

await db.execute("UPDATE accounts SET balance = balance - ? WHERE name = ?", (amount, name))
await db.execute("INSERT INTO transactions (account_name, action, amount) VALUES (?, ?, ?)",
(name, "Withdrawn", amount))
await db.commit()
else:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute("SELECT balance FROM accounts WHERE name = ?", (name,)) as cursor:
row = await cursor.fetchone()
if row is None:
raise AccountNotFoundError(f"Account '{name}' not found")
balance = row[0]

if amount > balance:
raise InsufficientFundsError(f"Cannot withdraw {amount:.2f}; balance is only {balance:.2f}")

await db.execute("UPDATE accounts SET balance = balance - ? WHERE name = ?", (amount, name))
await db.execute("INSERT INTO transactions (account_name, action, amount) VALUES (?, ?, ?)",
(name, "Withdrawn", amount))
await db.commit()

async def transfer(self, sender: str, recipient: str, amount: float):
if amount <= 0:
raise NegativeAmountError("Amount must be positive")
if sender == recipient:
raise ValueError("Cannot transfer to the same account")

if self.db_path == ":memory:":
db = await self.get_db()

# Lazy init lock if it doesn't exist (e.g. production first run)
if self._lock is None:
self._lock = asyncio.Lock()

async with self._lock:
try:
await db.execute("BEGIN TRANSACTION")

async with db.execute("SELECT balance FROM accounts WHERE name = ?", (sender,)) as cursor:
row = await cursor.fetchone()
if row is None:
raise AccountNotFoundError(f"Account '{sender}' not found")
sender_balance = row[0]

if amount > sender_balance:
raise InsufficientFundsError(f"Cannot withdraw {amount:.2f}; balance is only {sender_balance:.2f}")

async with db.execute("SELECT 1 FROM accounts WHERE name = ?", (recipient,)) as cursor:
if await cursor.fetchone() is None:
raise AccountNotFoundError(f"Account '{recipient}' not found")

await db.execute("UPDATE accounts SET balance = balance - ? WHERE name = ?", (amount, sender))
await db.execute("UPDATE accounts SET balance = balance + ? WHERE name = ?", (amount, recipient))

await db.execute("INSERT INTO transactions (account_name, action, amount, other_party) VALUES (?, ?, ?, ?)",
(sender, "Transferred to", amount, recipient))
await db.execute("INSERT INTO transactions (account_name, action, amount, other_party) VALUES (?, ?, ?, ?)",
(recipient, "Received from", amount, sender))

await db.commit()
except Exception as e:
await db.rollback()
raise e
else:
async with aiosqlite.connect(self.db_path) as db:
try:
await db.execute("BEGIN TRANSACTION")

async with db.execute("SELECT balance FROM accounts WHERE name = ?", (sender,)) as cursor:
row = await cursor.fetchone()
if row is None:
raise AccountNotFoundError(f"Account '{sender}' not found")
sender_balance = row[0]

if amount > sender_balance:
raise InsufficientFundsError(f"Cannot withdraw {amount:.2f}; balance is only {sender_balance:.2f}")

async with db.execute("SELECT 1 FROM accounts WHERE name = ?", (recipient,)) as cursor:
if await cursor.fetchone() is None:
raise AccountNotFoundError(f"Account '{recipient}' not found")

await db.execute("UPDATE accounts SET balance = balance - ? WHERE name = ?", (amount, sender))
await db.execute("UPDATE accounts SET balance = balance + ? WHERE name = ?", (amount, recipient))

await db.execute("INSERT INTO transactions (account_name, action, amount, other_party) VALUES (?, ?, ?, ?)",
(sender, "Transferred to", amount, recipient))
await db.execute("INSERT INTO transactions (account_name, action, amount, other_party) VALUES (?, ?, ?, ?)",
(recipient, "Received from", amount, sender))

await db.commit()
except Exception as e:
await db.rollback()
raise e

async def get_transaction_history(self, name: str) -> List[str]:
if self.db_path == ":memory:":
db = await self.get_db()
async with db.execute("SELECT 1 FROM accounts WHERE name = ?", (name,)) as cursor:
if await cursor.fetchone() is None:
raise AccountNotFoundError(f"Account '{name}' not found")

async with db.execute("SELECT action, amount, other_party FROM transactions WHERE account_name = ? ORDER BY timestamp ASC", (name,)) as cursor:
rows = await cursor.fetchall()
history = []
for action, amount, other_party in rows:
entry = f"{action}: {amount:.2f}"
if other_party:
entry += f" {other_party}"
history.append(entry)
return history
else:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute("SELECT 1 FROM accounts WHERE name = ?", (name,)) as cursor:
if await cursor.fetchone() is None:
raise AccountNotFoundError(f"Account '{name}' not found")

async with db.execute("SELECT action, amount, other_party FROM transactions WHERE account_name = ? ORDER BY timestamp ASC", (name,)) as cursor:
rows = await cursor.fetchall()
history = []
for action, amount, other_party in rows:
entry = f"{action}: {amount:.2f}"
if other_party:
entry += f" {other_party}"
history.append(entry)
return history
35 changes: 19 additions & 16 deletions banking/main.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
from fastapi import FastAPI, HTTPException
import os
from contextlib import asynccontextmanager
from pydantic import BaseModel
from banking.errors import AccountNotFoundError, InsufficientFundsError, NegativeAmountError
from banking.models import (
Bank
)
from banking.database import Database

app = FastAPI(title="Simple Banking API")
bank = Bank()
db = Database()

@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: Initialize DB
await db.init_db()
yield
# Shutdown: (Optional cleanup)

app = FastAPI(title="Simple Banking API", lifespan=lifespan)


class AccountCreate(BaseModel):
Expand All @@ -33,26 +40,25 @@ async def root():
@app.post("/accounts/")
async def create_account(data: AccountCreate):
try:
account = bank.create_account(data.name, data.initial_balance)
return {"message": f"Account '{account.name}' created."}
await db.create_account(data.name, data.initial_balance)
return {"message": f"Account '{data.name}' created."}
except ValueError as e:
raise HTTPException(400, str(e))


@app.get("/accounts/{name}")
async def get_balance(name: str):
try:
account = bank.get_account(name)
return {"name": account.name, "balance": account.balance}
balance = await db.get_balance(name)
return {"name": name, "balance": balance}
except AccountNotFoundError as e:
raise HTTPException(404, str(e))


@app.post("/accounts/{name}/deposits")
async def deposit(name: str, data: TransactionAmount):
try:
account = bank.get_account(name)
account.deposit(data.amount)
await db.deposit(name, data.amount)
return {"message": f"{data.amount:.2f} deposited to {name}"}
except (AccountNotFoundError, NegativeAmountError) as e:
raise HTTPException(400, str(e))
Expand All @@ -61,8 +67,7 @@ async def deposit(name: str, data: TransactionAmount):
@app.post("/accounts/{name}/withdrawals")
async def withdraw(name: str, data: TransactionAmount):
try:
account = bank.get_account(name)
account.withdraw(data.amount)
await db.withdraw(name, data.amount)
return {"message": f"{data.amount:.2f} withdrawn from {name}"}
except (AccountNotFoundError, NegativeAmountError, InsufficientFundsError) as e:
raise HTTPException(400, str(e))
Expand All @@ -71,9 +76,7 @@ async def withdraw(name: str, data: TransactionAmount):
@app.post("/transfers")
async def transfer(data: Transfer):
try:
sender = bank.get_account(data.sender)
recipient = bank.get_account(data.recipient)
sender.transfer(recipient, data.amount)
await db.transfer(data.sender, data.recipient, data.amount)
return {"message": f"{data.amount:.2f} transferred from {data.sender} to {data.recipient}"}
except (AccountNotFoundError, NegativeAmountError, InsufficientFundsError, ValueError) as e:
raise HTTPException(400, str(e))
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
asyncio_mode = "strict"
asyncio_default_fixture_loop_scope = "function"
4 changes: 3 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ pytest==8.3.5
pytest-cov==6.1.1
sniffio==1.3.1
starlette==0.46.2
tomli==2.2.1
tomli>=2.0.1
typing-inspection==0.4.0
typing_extensions==4.13.2
uvicorn==0.34.2
aiosqlite==0.21.0
pytest-asyncio==0.25.3
Loading
Loading