From 60ee726f7fc5faf12504d137282751a72dc2d267 Mon Sep 17 00:00:00 2001 From: nicolewiktor Date: Sat, 21 Mar 2026 18:41:52 -0400 Subject: [PATCH 01/15] feat: add initial specs for transaction review service --- SPECS/decision-evaluation.md | 0 SPECS/decision-filtering.md | 0 SPECS/transaction-retrieval.md | 0 SPECS/transaction-submission.md | 0 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 SPECS/decision-evaluation.md create mode 100644 SPECS/decision-filtering.md create mode 100644 SPECS/transaction-retrieval.md create mode 100644 SPECS/transaction-submission.md diff --git a/SPECS/decision-evaluation.md b/SPECS/decision-evaluation.md new file mode 100644 index 00000000..e69de29b diff --git a/SPECS/decision-filtering.md b/SPECS/decision-filtering.md new file mode 100644 index 00000000..e69de29b diff --git a/SPECS/transaction-retrieval.md b/SPECS/transaction-retrieval.md new file mode 100644 index 00000000..e69de29b diff --git a/SPECS/transaction-submission.md b/SPECS/transaction-submission.md new file mode 100644 index 00000000..e69de29b From 5a0f1072d8e47f09657811e977b9917ff0190236 Mon Sep 17 00:00:00 2001 From: nicolewiktor Date: Sat, 21 Mar 2026 19:03:23 -0400 Subject: [PATCH 02/15] chore: add TODO.md with implementation plan --- TODO.md | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index b5d82042..3064a22b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,34 @@ # TODO +## Feature Implementation + +- [ ] Implement transaction submission endpoint +- [ ] Add request validation for transaction fields +- [ ] Store transactions in SQLite database + +- [ ] Implement decision evaluation logic +- [ ] Return decision and reasons in API response + +- [ ] Implement transaction retrieval endpoint (GET /transactions) +- [ ] Implement transaction retrieval by ID +- [ ] Implement filtering by decision + +## Testing + +- [ ] Add tests for valid transaction submission +- [ ] Add tests for invalid transaction input +- [ ] Add tests for rejection rules +- [ ] Add tests for review rules +- [ ] Add tests for approved transactions +- [ ] Add tests for transaction retrieval +- [ ] Add tests for filtering by decision + +--- + ## Refactor Proposals -- + +- ## New Feature Proposals -- \ No newline at end of file + +- \ No newline at end of file From b1e1e06b65ca8269d75a57e9e9f56e70d2310cf1 Mon Sep 17 00:00:00 2001 From: nicolewiktor Date: Sat, 21 Mar 2026 19:27:46 -0400 Subject: [PATCH 03/15] feat: setup database, models, and schemas --- app/db.py | 21 +++++++++++++++++++++ app/models.py | 21 +++++++++++++++++++++ app/schemas.py | 21 +++++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 app/db.py create mode 100644 app/models.py create mode 100644 app/schemas.py diff --git a/app/db.py b/app/db.py new file mode 100644 index 00000000..4d4875c4 --- /dev/null +++ b/app/db.py @@ -0,0 +1,21 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +DATABASE_URL = "sqlite:///./transactions.db" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/models.py b/app/models.py new file mode 100644 index 00000000..8c819d66 --- /dev/null +++ b/app/models.py @@ -0,0 +1,21 @@ +from decimal import Decimal + +from sqlalchemy import Boolean, Integer, JSON, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from .db import Base + + +class Transaction(Base): + __tablename__ = "transactions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + account_id: Mapped[str] = mapped_column(String(64), index=True) + amount: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + country: Mapped[str] = mapped_column(String(2)) + available_balance: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + account_status: Mapped[str] = mapped_column(String(32)) + transaction_type: Mapped[str] = mapped_column(String(32)) + new_payee: Mapped[bool] = mapped_column(Boolean, default=False) + decision: Mapped[str] = mapped_column(String(32), default="pending") + reasons: Mapped[list[str]] = mapped_column(JSON, default=list) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 00000000..5d983a4f --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,21 @@ +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict + + +class TransactionCreate(BaseModel): + account_id: str + amount: Decimal + country: str + available_balance: Decimal + account_status: str + transaction_type: str + new_payee: bool + + +class TransactionResponse(BaseModel): + id: int + decision: str + reasons: list[str] + + model_config = ConfigDict(from_attributes=True) From cef8b4090b79c206347efef24169730fa8e3ac4e Mon Sep 17 00:00:00 2001 From: nicolewiktor Date: Sat, 21 Mar 2026 19:52:51 -0400 Subject: [PATCH 04/15] feat: implement transaction submission with validation and persistence --- .gitignore | 13 +++++++++++++ TODO.md | 4 ---- app/main.py | 13 +++++++++++++ app/routes/transactions.py | 38 ++++++++++++++++++++++++++++++++++++++ requirements.txt | 4 ++++ 5 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 app/main.py create mode 100644 app/routes/transactions.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index e69de29b..1819b058 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,13 @@ +# Python +__pycache__/ +*.pyc + +# Virtual env +.venv/ +env/ + +# Database +*.db + +# OS +.DS_Store \ No newline at end of file diff --git a/TODO.md b/TODO.md index 3064a22b..b94d5502 100644 --- a/TODO.md +++ b/TODO.md @@ -2,10 +2,6 @@ ## Feature Implementation -- [ ] Implement transaction submission endpoint -- [ ] Add request validation for transaction fields -- [ ] Store transactions in SQLite database - - [ ] Implement decision evaluation logic - [ ] Return decision and reasons in API response diff --git a/app/main.py b/app/main.py new file mode 100644 index 00000000..dacf4e3e --- /dev/null +++ b/app/main.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI + +from .db import Base, engine +from . import models +from .routes.transactions import router as transactions_router + +# Ensure SQLAlchemy models are registered before table creation. +_ = models + +Base.metadata.create_all(bind=engine) + +app = FastAPI(title="Transaction Review Service") +app.include_router(transactions_router) diff --git a/app/routes/transactions.py b/app/routes/transactions.py new file mode 100644 index 00000000..d9b969be --- /dev/null +++ b/app/routes/transactions.py @@ -0,0 +1,38 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..db import get_db +from ..models import Transaction +from ..schemas import TransactionCreate, TransactionResponse + +router = APIRouter(tags=["transactions"]) + + +@router.post( + "/transactions", + response_model=TransactionResponse, + status_code=status.HTTP_201_CREATED, +) +def create_transaction( + payload: TransactionCreate, + db: Session = Depends(get_db), +) -> Transaction: + if payload.amount <= 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="amount must be greater than 0", + ) + + transaction = Transaction( + account_id=payload.account_id, + amount=payload.amount, + country=payload.country, + available_balance=payload.available_balance, + account_status=payload.account_status, + transaction_type=payload.transaction_type, + new_payee=payload.new_payee, + ) + db.add(transaction) + db.commit() + db.refresh(transaction) + return transaction diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..ded7aa81 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.110,<1.0 +uvicorn[standard]>=0.29,<1.0 +sqlalchemy>=2.0,<3.0 +pydantic>=2.6,<3.0 From 979bb8ba1c298230cddffd752868464b4287363c Mon Sep 17 00:00:00 2001 From: nicolewiktor Date: Sat, 21 Mar 2026 20:05:21 -0400 Subject: [PATCH 05/15] feat: add deterministic transaction decision evaluation --- TODO.md | 3 --- app/routes/transactions.py | 11 +++++------ app/services/decision_engine.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 app/services/decision_engine.py diff --git a/TODO.md b/TODO.md index b94d5502..55ed5b67 100644 --- a/TODO.md +++ b/TODO.md @@ -2,9 +2,6 @@ ## Feature Implementation -- [ ] Implement decision evaluation logic -- [ ] Return decision and reasons in API response - - [ ] Implement transaction retrieval endpoint (GET /transactions) - [ ] Implement transaction retrieval by ID - [ ] Implement filtering by decision diff --git a/app/routes/transactions.py b/app/routes/transactions.py index d9b969be..de6e0e53 100644 --- a/app/routes/transactions.py +++ b/app/routes/transactions.py @@ -1,9 +1,10 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, status from sqlalchemy.orm import Session from ..db import get_db from ..models import Transaction from ..schemas import TransactionCreate, TransactionResponse +from ..services.decision_engine import evaluate_transaction router = APIRouter(tags=["transactions"]) @@ -17,11 +18,7 @@ def create_transaction( payload: TransactionCreate, db: Session = Depends(get_db), ) -> Transaction: - if payload.amount <= 0: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="amount must be greater than 0", - ) + decision, reasons = evaluate_transaction(payload) transaction = Transaction( account_id=payload.account_id, @@ -31,6 +28,8 @@ def create_transaction( account_status=payload.account_status, transaction_type=payload.transaction_type, new_payee=payload.new_payee, + decision=decision, + reasons=reasons, ) db.add(transaction) db.commit() diff --git a/app/services/decision_engine.py b/app/services/decision_engine.py new file mode 100644 index 00000000..5dc8cfae --- /dev/null +++ b/app/services/decision_engine.py @@ -0,0 +1,31 @@ +from ..schemas import TransactionCreate + + +def evaluate_transaction(payload: TransactionCreate) -> tuple[str, list[str]]: + reject_reasons: list[str] = [] + + if payload.account_status.strip().lower() != "active": + reject_reasons.append("Rejected: account status is not active.") + if payload.amount <= 0: + reject_reasons.append("Rejected: amount must be greater than 0.") + if payload.amount > payload.available_balance: + reject_reasons.append("Rejected: amount exceeds available balance.") + + if reject_reasons: + return "rejected", reject_reasons + + review_reasons: list[str] = [] + + if payload.amount >= 5000: + review_reasons.append("Review: amount is 5000 or higher.") + if payload.country.strip().upper() != "US": + review_reasons.append("Review: transaction country is not US.") + if payload.new_payee and payload.amount >= 1000: + review_reasons.append("Review: new payee transaction is 1000 or higher.") + if payload.transaction_type.strip().lower() == "wire" and payload.amount >= 2000: + review_reasons.append("Review: wire transaction is 2000 or higher.") + + if review_reasons: + return "review", review_reasons + + return "approved", [] From b351a1964597862d4d15ffa432a895f301116726 Mon Sep 17 00:00:00 2001 From: nicolewiktor Date: Sat, 21 Mar 2026 20:31:51 -0400 Subject: [PATCH 06/15] feat: add transaction retrieval and decision filtering --- TODO.md | 4 ---- app/routes/transactions.py | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index 55ed5b67..7ef6a36a 100644 --- a/TODO.md +++ b/TODO.md @@ -2,10 +2,6 @@ ## Feature Implementation -- [ ] Implement transaction retrieval endpoint (GET /transactions) -- [ ] Implement transaction retrieval by ID -- [ ] Implement filtering by decision - ## Testing - [ ] Add tests for valid transaction submission diff --git a/app/routes/transactions.py b/app/routes/transactions.py index de6e0e53..6533d791 100644 --- a/app/routes/transactions.py +++ b/app/routes/transactions.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter, Depends, status +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from ..db import get_db @@ -7,6 +9,7 @@ from ..services.decision_engine import evaluate_transaction router = APIRouter(tags=["transactions"]) +ALLOWED_DECISIONS = {"approved", "review", "rejected"} @router.post( @@ -35,3 +38,34 @@ def create_transaction( db.commit() db.refresh(transaction) return transaction + + +@router.get("/transactions", response_model=list[TransactionResponse]) +def list_transactions( + decision: Optional[str] = Query(default=None), + db: Session = Depends(get_db), +) -> list[Transaction]: + if decision is not None and decision not in ALLOWED_DECISIONS: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Invalid decision filter. Allowed values: approved, review, rejected.", + ) + + query = db.query(Transaction) + if decision is not None: + query = query.filter(Transaction.decision == decision) + return query.all() + + +@router.get("/transactions/{transaction_id}", response_model=TransactionResponse) +def get_transaction( + transaction_id: int, + db: Session = Depends(get_db), +) -> Transaction: + transaction = db.query(Transaction).filter(Transaction.id == transaction_id).first() + if transaction is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Transaction not found.", + ) + return transaction From d6e3c2ebd8ba3163689cbad50a4197942c583994 Mon Sep 17 00:00:00 2001 From: nicolewiktor Date: Sun, 22 Mar 2026 09:22:24 -0400 Subject: [PATCH 07/15] test: add coverage for submission, evaluation, retrieval, and filtering --- app/routes/transactions.py | 8 +- app/services/decision_engine.py | 2 - requirements.txt | 2 + tests/conftest.py | 34 +++++++ tests/test_transactions.py | 161 ++++++++++++++++++++++++++++++++ 5 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_transactions.py diff --git a/app/routes/transactions.py b/app/routes/transactions.py index 6533d791..fb0c9f53 100644 --- a/app/routes/transactions.py +++ b/app/routes/transactions.py @@ -21,6 +21,12 @@ def create_transaction( payload: TransactionCreate, db: Session = Depends(get_db), ) -> Transaction: + if payload.amount <= 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="amount must be greater than 0", + ) + decision, reasons = evaluate_transaction(payload) transaction = Transaction( @@ -47,7 +53,7 @@ def list_transactions( ) -> list[Transaction]: if decision is not None and decision not in ALLOWED_DECISIONS: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid decision filter. Allowed values: approved, review, rejected.", ) diff --git a/app/services/decision_engine.py b/app/services/decision_engine.py index 5dc8cfae..b961ed33 100644 --- a/app/services/decision_engine.py +++ b/app/services/decision_engine.py @@ -6,8 +6,6 @@ def evaluate_transaction(payload: TransactionCreate) -> tuple[str, list[str]]: if payload.account_status.strip().lower() != "active": reject_reasons.append("Rejected: account status is not active.") - if payload.amount <= 0: - reject_reasons.append("Rejected: amount must be greater than 0.") if payload.amount > payload.available_balance: reject_reasons.append("Rejected: amount exceeds available balance.") diff --git a/requirements.txt b/requirements.txt index ded7aa81..b6e4ed7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ fastapi>=0.110,<1.0 uvicorn[standard]>=0.29,<1.0 sqlalchemy>=2.0,<3.0 pydantic>=2.6,<3.0 +pytest>=8.0,<9.0 +httpx>=0.27,<1.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..1d29fffa --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,34 @@ +from collections.abc import Generator + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.db import Base, get_db +from app.main import app + + +@pytest.fixture +def client(tmp_path) -> Generator[TestClient, None, None]: + db_path = tmp_path / "test_transactions.db" + test_engine = create_engine( + f"sqlite:///{db_path}", + connect_args={"check_same_thread": False}, + ) + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine) + + Base.metadata.create_all(bind=test_engine) + + def override_get_db() -> Generator[Session, None, None]: + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() + Base.metadata.drop_all(bind=test_engine) diff --git a/tests/test_transactions.py b/tests/test_transactions.py new file mode 100644 index 00000000..54badc87 --- /dev/null +++ b/tests/test_transactions.py @@ -0,0 +1,161 @@ +def build_payload(**overrides): + payload = { + "account_id": "acct-123", + "amount": 100.0, + "country": "US", + "available_balance": 1000.0, + "account_status": "active", + "transaction_type": "card", + "new_payee": False, + } + payload.update(overrides) + return payload + + +def create_transaction(client, **overrides): + return client.post("/transactions", json=build_payload(**overrides)) + + +def test_valid_transaction_submission_succeeds(client): + response = create_transaction(client) + assert response.status_code == 201 + body = response.json() + assert body["id"] > 0 + assert body["decision"] in {"approved", "review", "rejected"} + assert isinstance(body["reasons"], list) + + +def test_missing_required_field_returns_error(client): + payload = build_payload() + payload.pop("account_id") + response = client.post("/transactions", json=payload) + assert response.status_code == 422 + + +def test_invalid_field_type_returns_error(client): + response = create_transaction(client, amount="not-a-number") + assert response.status_code == 422 + + +def test_non_positive_amount_returns_error(client): + response = create_transaction(client, amount=0) + assert response.status_code == 422 + + +def test_inactive_account_is_rejected(client): + response = create_transaction(client, account_status="inactive") + assert response.status_code == 201 + body = response.json() + assert body["decision"] == "rejected" + assert any("account status is not active" in reason.lower() for reason in body["reasons"]) + + +def test_transaction_exceeding_available_balance_is_rejected(client): + response = create_transaction(client, amount=1500, available_balance=1000) + assert response.status_code == 201 + body = response.json() + assert body["decision"] == "rejected" + assert any("exceeds available balance" in reason.lower() for reason in body["reasons"]) + + +def test_amount_ge_5000_is_marked_for_review(client): + response = create_transaction(client, amount=5000, available_balance=10000) + assert response.status_code == 201 + assert response.json()["decision"] == "review" + + +def test_non_us_country_is_marked_for_review(client): + response = create_transaction(client, country="CA") + assert response.status_code == 201 + assert response.json()["decision"] == "review" + + +def test_new_payee_with_amount_ge_1000_is_marked_for_review(client): + response = create_transaction(client, new_payee=True, amount=1000) + assert response.status_code == 201 + assert response.json()["decision"] == "review" + + +def test_wire_transaction_with_amount_ge_2000_is_marked_for_review(client): + response = create_transaction( + client, + transaction_type="wire", + amount=2000, + available_balance=5000, + ) + assert response.status_code == 201 + assert response.json()["decision"] == "review" + + +def test_transaction_with_no_triggered_rules_is_approved(client): + response = create_transaction(client, amount=250, country="US", transaction_type="card", new_payee=False) + assert response.status_code == 201 + assert response.json()["decision"] == "approved" + + +def test_get_all_transactions_returns_stored_transactions(client): + create_transaction(client) + create_transaction(client, country="CA") + + response = client.get("/transactions") + assert response.status_code == 200 + body = response.json() + assert len(body) == 2 + + +def test_get_transaction_by_id_returns_correct_record(client): + first = create_transaction(client).json() + create_transaction(client, country="CA") + + response = client.get(f"/transactions/{first['id']}") + assert response.status_code == 200 + assert response.json()["id"] == first["id"] + + +def test_non_existent_transaction_id_returns_404(client): + response = client.get("/transactions/999999") + assert response.status_code == 404 + + +def test_decision_approved_returns_only_approved_transactions(client): + approved = create_transaction(client, amount=100, country="US").json() + create_transaction(client, country="CA") + create_transaction(client, account_status="inactive") + + response = client.get("/transactions?decision=approved") + assert response.status_code == 200 + body = response.json() + assert len(body) == 1 + assert body[0]["id"] == approved["id"] + assert all(item["decision"] == "approved" for item in body) + + +def test_decision_review_returns_only_review_transactions(client): + create_transaction(client, amount=100, country="US") + review = create_transaction(client, country="CA").json() + create_transaction(client, account_status="inactive") + + response = client.get("/transactions?decision=review") + assert response.status_code == 200 + body = response.json() + assert len(body) == 1 + assert body[0]["id"] == review["id"] + assert all(item["decision"] == "review" for item in body) + + +def test_decision_rejected_returns_only_rejected_transactions(client): + create_transaction(client, amount=100, country="US") + create_transaction(client, country="CA") + rejected = create_transaction(client, account_status="inactive").json() + + response = client.get("/transactions?decision=rejected") + assert response.status_code == 200 + body = response.json() + assert len(body) == 1 + assert body[0]["id"] == rejected["id"] + assert all(item["decision"] == "rejected" for item in body) + + +def test_invalid_decision_filter_returns_error(client): + response = client.get("/transactions?decision=unknown") + assert response.status_code == 422 From 21555106fd290725473a78b9229fb427b127355b Mon Sep 17 00:00:00 2001 From: nicolewiktor Date: Sun, 22 Mar 2026 09:46:40 -0400 Subject: [PATCH 08/15] feat: add transaction summary spec and clean up completed TODO items --- SPECS/transaction-summary.md | 0 TODO.md | 15 ++------------- 2 files changed, 2 insertions(+), 13 deletions(-) create mode 100644 SPECS/transaction-summary.md diff --git a/SPECS/transaction-summary.md b/SPECS/transaction-summary.md new file mode 100644 index 00000000..e69de29b diff --git a/TODO.md b/TODO.md index 7ef6a36a..e8f77a59 100644 --- a/TODO.md +++ b/TODO.md @@ -1,22 +1,11 @@ # TODO ## Feature Implementation +- [ ] Implement transaction summary endpoint ## Testing +- [ ] Add tests for transaction summary -- [ ] Add tests for valid transaction submission -- [ ] Add tests for invalid transaction input -- [ ] Add tests for rejection rules -- [ ] Add tests for review rules -- [ ] Add tests for approved transactions -- [ ] Add tests for transaction retrieval -- [ ] Add tests for filtering by decision - ---- - -## Refactor Proposals - -- ## New Feature Proposals From 493b873a881997df93112f982297d5096fdcfb25 Mon Sep 17 00:00:00 2001 From: nicolewiktor Date: Sun, 22 Mar 2026 10:21:06 -0400 Subject: [PATCH 09/15] feat: implement transaction summary endpoint with tests --- app/routes/transactions.py | 14 ++++++++++++-- tests/conftest.py | 7 +++++++ tests/test_transactions.py | 26 ++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/app/routes/transactions.py b/app/routes/transactions.py index fb0c9f53..57c4ed05 100644 --- a/app/routes/transactions.py +++ b/app/routes/transactions.py @@ -23,7 +23,7 @@ def create_transaction( ) -> Transaction: if payload.amount <= 0: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + status_code=422, detail="amount must be greater than 0", ) @@ -53,7 +53,7 @@ def list_transactions( ) -> list[Transaction]: if decision is not None and decision not in ALLOWED_DECISIONS: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + status_code=422, detail="Invalid decision filter. Allowed values: approved, review, rejected.", ) @@ -63,6 +63,16 @@ def list_transactions( return query.all() +@router.get("/transactions/summary") +def get_transaction_summary(db: Session = Depends(get_db)) -> dict[str, int]: + return { + "total": db.query(Transaction).count(), + "approved": db.query(Transaction).filter(Transaction.decision == "approved").count(), + "review": db.query(Transaction).filter(Transaction.decision == "review").count(), + "rejected": db.query(Transaction).filter(Transaction.decision == "rejected").count(), + } + + @router.get("/transactions/{transaction_id}", response_model=TransactionResponse) def get_transaction( transaction_id: int, diff --git a/tests/conftest.py b/tests/conftest.py index 1d29fffa..c4601efc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,17 @@ from collections.abc import Generator +from pathlib import Path +import sys import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker +# Ensure "app" imports work regardless of current working directory. +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + from app.db import Base, get_db from app.main import app diff --git a/tests/test_transactions.py b/tests/test_transactions.py index 54badc87..a3655aa7 100644 --- a/tests/test_transactions.py +++ b/tests/test_transactions.py @@ -159,3 +159,29 @@ def test_decision_rejected_returns_only_rejected_transactions(client): def test_invalid_decision_filter_returns_error(client): response = client.get("/transactions?decision=unknown") assert response.status_code == 422 + + +def test_transaction_summary_returns_zero_counts_when_empty(client): + response = client.get("/transactions/summary") + assert response.status_code == 200 + assert response.json() == { + "total": 0, + "approved": 0, + "review": 0, + "rejected": 0, + } + + +def test_transaction_summary_returns_correct_counts(client): + create_transaction(client, amount=100, country="US") + create_transaction(client, country="CA") + create_transaction(client, account_status="inactive") + + response = client.get("/transactions/summary") + assert response.status_code == 200 + assert response.json() == { + "total": 3, + "approved": 1, + "review": 1, + "rejected": 1, + } From fa6f060d33174189d6ed4f234248095cf3f91538 Mon Sep 17 00:00:00 2001 From: nicolewiktor Date: Sun, 22 Mar 2026 10:27:25 -0400 Subject: [PATCH 10/15] chore: add response schema for transaction summary endpoint --- app/routes/transactions.py | 4 ++-- app/schemas.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/routes/transactions.py b/app/routes/transactions.py index 57c4ed05..6c901611 100644 --- a/app/routes/transactions.py +++ b/app/routes/transactions.py @@ -5,7 +5,7 @@ from ..db import get_db from ..models import Transaction -from ..schemas import TransactionCreate, TransactionResponse +from ..schemas import TransactionCreate, TransactionResponse, TransactionSummaryResponse from ..services.decision_engine import evaluate_transaction router = APIRouter(tags=["transactions"]) @@ -63,7 +63,7 @@ def list_transactions( return query.all() -@router.get("/transactions/summary") +@router.get("/transactions/summary", response_model=TransactionSummaryResponse) def get_transaction_summary(db: Session = Depends(get_db)) -> dict[str, int]: return { "total": db.query(Transaction).count(), diff --git a/app/schemas.py b/app/schemas.py index 5d983a4f..451aedd5 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -19,3 +19,10 @@ class TransactionResponse(BaseModel): reasons: list[str] model_config = ConfigDict(from_attributes=True) + + +class TransactionSummaryResponse(BaseModel): + total: int + approved: int + review: int + rejected: int From fbd3d68fc9361fe1a39844501390d42e9436e472 Mon Sep 17 00:00:00 2001 From: nicolewiktor Date: Sun, 22 Mar 2026 11:10:33 -0400 Subject: [PATCH 11/15] docs: clarify validation handling for non-positive amounts --- SPECS/decision-evaluation.md | 46 ++++++++++++++++++++++++++++++++++++ tests/test_transactions.py | 12 +++++++--- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/SPECS/decision-evaluation.md b/SPECS/decision-evaluation.md index e69de29b..30ee80f1 100644 --- a/SPECS/decision-evaluation.md +++ b/SPECS/decision-evaluation.md @@ -0,0 +1,46 @@ +# Feature Spec: Decision Evaluation + +## Goal +Evaluate a submitted transaction and return a deterministic decision (`approved`, `review`, or `rejected`) along with clear reasons. + +## Scope +- In: + - Applying rule-based decision logic + - Returning decision outcomes and reasons +- Out: + - Machine learning or AI-based decision logic + - Dynamically configurable rules + +## Decision Logic + +### Rejection Rules (highest priority) +A transaction is rejected if any of the following are true: +- account_status is not "active" +- amount is greater than available_balance +Note: Invalid inputs (e.g., amount <= 0) are handled at the API validation layer and return a 422 error. The decision engine only evaluates valid transactions. + +### Review Rules +If the transaction is not rejected, it is marked for review if any of the following are true: +- amount >= 5000 +- country is not "US" +- new_payee is true AND amount >= 1000 +- transaction_type is "wire" AND amount >= 2000 + +### Approval Rule +A transaction is approved only if none of the rejection or review rules apply. + +## Output +- The system returns: + - decision: one of `approved`, `review`, `rejected` + - reasons: a list of human-readable explanations for triggered rules + +## Acceptance Criteria +- [ ] Inactive accounts are rejected +- [ ] Transactions exceeding available balance are rejected + +- [ ] Large transactions are marked for review +- [ ] International transactions are marked for review +- [ ] New payee transactions meeting threshold are marked for review +- [ ] Wire transactions meeting threshold are marked for review +- [ ] Transactions with no triggered rules are approved +- [ ] Response includes decision reasons when rules are triggered \ No newline at end of file diff --git a/tests/test_transactions.py b/tests/test_transactions.py index a3655aa7..710128bf 100644 --- a/tests/test_transactions.py +++ b/tests/test_transactions.py @@ -112,9 +112,11 @@ def test_get_transaction_by_id_returns_correct_record(client): assert response.json()["id"] == first["id"] -def test_non_existent_transaction_id_returns_404(client): - response = client.get("/transactions/999999") +def test_transaction_retrieval_by_missing_id_returns_404(client): + response = client.get("/transactions/9999") assert response.status_code == 404 + body = response.json() + assert body["detail"] == "Transaction not found." def test_decision_approved_returns_only_approved_transactions(client): @@ -157,8 +159,10 @@ def test_decision_rejected_returns_only_rejected_transactions(client): def test_invalid_decision_filter_returns_error(client): - response = client.get("/transactions?decision=unknown") + response = client.get("/transactions?decision=blah") assert response.status_code == 422 + body = response.json() + assert "invalid decision filter" in body["detail"].lower() def test_transaction_summary_returns_zero_counts_when_empty(client): @@ -185,3 +189,5 @@ def test_transaction_summary_returns_correct_counts(client): "review": 1, "rejected": 1, } + + From af7dc2f5da002e3d147a49572a111cb67874ebed Mon Sep 17 00:00:00 2001 From: nicolewiktor Date: Sun, 22 Mar 2026 16:51:54 -0400 Subject: [PATCH 12/15] feat: add health check specs and align specs with implementation --- SPECS/decision-evaluation.md | 18 +++++++-------- SPECS/decision-filtering.md | 23 +++++++++++++++++++ SPECS/health-check.md | 16 ++++++++++++++ SPECS/transaction-retrieval.md | 29 ++++++++++++++++++++++++ SPECS/transaction-submission.md | 39 +++++++++++++++++++++++++++++++++ SPECS/transaction-summary.md | 25 +++++++++++++++++++++ TODO.md | 2 +- app/main.py | 6 +++++ app/schemas.py | 4 ++++ 9 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 SPECS/health-check.md diff --git a/SPECS/decision-evaluation.md b/SPECS/decision-evaluation.md index 30ee80f1..a3920bcf 100644 --- a/SPECS/decision-evaluation.md +++ b/SPECS/decision-evaluation.md @@ -35,12 +35,12 @@ A transaction is approved only if none of the rejection or review rules apply. - reasons: a list of human-readable explanations for triggered rules ## Acceptance Criteria -- [ ] Inactive accounts are rejected -- [ ] Transactions exceeding available balance are rejected - -- [ ] Large transactions are marked for review -- [ ] International transactions are marked for review -- [ ] New payee transactions meeting threshold are marked for review -- [ ] Wire transactions meeting threshold are marked for review -- [ ] Transactions with no triggered rules are approved -- [ ] Response includes decision reasons when rules are triggered \ No newline at end of file +- [X] Inactive accounts are rejected +- [X] Transactions exceeding available balance are rejected + +- [X] Large transactions are marked for review +- [X] International transactions are marked for review +- [X] New payee transactions meeting threshold are marked for review +- [X] Wire transactions meeting threshold are marked for review +- [X] Transactions with no triggered rules are approved +- [X] Response includes decision reasons when rules are triggered \ No newline at end of file diff --git a/SPECS/decision-filtering.md b/SPECS/decision-filtering.md index e69de29b..e84142a5 100644 --- a/SPECS/decision-filtering.md +++ b/SPECS/decision-filtering.md @@ -0,0 +1,23 @@ +# Feature Spec: Decision Filtering + +## Goal +Allow users to filter transactions by decision outcome. + +## Scope +- In: + - Filtering by decision type +- Out: + - Complex multi-field search + +## Behavior +- The system supports filtering transactions by decision: + - approved + - review + - rejected +- Invalid filter values return an error + +## Acceptance Criteria +- [X] Filtering by approved returns only approved transactions +- [X] Filtering by review returns only review transactions +- [X] Filtering by rejected returns only rejected transactions +- [X] Invalid filter values return an error \ No newline at end of file diff --git a/SPECS/health-check.md b/SPECS/health-check.md new file mode 100644 index 00000000..bc5d946d --- /dev/null +++ b/SPECS/health-check.md @@ -0,0 +1,16 @@ +# Feature Spec: Health Check + +## Goal +Provide a simple endpoint to verify that the service is running. + +## Endpoint +GET / + +## Response +{ + "message": "Transaction Review Service is running. See /docs for API usage." +} + +## Acceptance Criteria +- [X] GET / returns status 200 +- [X] Response includes a message indicating the service is running \ No newline at end of file diff --git a/SPECS/transaction-retrieval.md b/SPECS/transaction-retrieval.md index e69de29b..cb8e1e15 100644 --- a/SPECS/transaction-retrieval.md +++ b/SPECS/transaction-retrieval.md @@ -0,0 +1,29 @@ +# Feature Spec: Transaction Retrieval + +## Goal +Allow users to retrieve stored transactions and their decisions. + +## Scope +- In: + - Retrieving all transactions + - Retrieving a transaction by ID +- Out: + - Pagination + - Sorting + +## Behavior + +### Retrieve All Transactions +- Returns all stored transactions +- Includes decision and reasons for each + +### Retrieve by ID +- Returns a single transaction by ID +- If the ID does not exist, return a not-found error + +## Acceptance Criteria +- [X] User can retrieve all transactions +- [X] User can retrieve a transaction by ID +- [X] Returned transactions include decision and reasons +- [X] Non-existent IDs return a not-found error + diff --git a/SPECS/transaction-submission.md b/SPECS/transaction-submission.md index e69de29b..8f089696 100644 --- a/SPECS/transaction-submission.md +++ b/SPECS/transaction-submission.md @@ -0,0 +1,39 @@ +# Feature Spec: Transaction Submission + +## Goal +Allow users to submit a transaction request for evaluation. + +## Scope +- In: + - Accepting transaction input via API + - Validating required fields and types + - Storing the transaction +- Out: + - Updating or deleting transactions + - Authentication and authorization + +## Input Requirements +Each transaction must include: +- account_id (string) +- amount (number) +- country (string) +- available_balance (number) +- account_status (string) +- transaction_type (string) +- new_payee (boolean) + +## Validation Rules +- All required fields must be present +- amount must be greater than 0 +- Field types must be correct + +## Behavior +- Valid transactions are stored with a unique ID +- Invalid requests return an error response + +## Acceptance Criteria +- [X] Valid transaction is accepted and stored +- [X] Stored transaction includes a unique ID +- [X] Missing required fields return an error +- [X] Invalid field types return an error +- [X] Non-positive amounts are rejected \ No newline at end of file diff --git a/SPECS/transaction-summary.md b/SPECS/transaction-summary.md index e69de29b..f85a4008 100644 --- a/SPECS/transaction-summary.md +++ b/SPECS/transaction-summary.md @@ -0,0 +1,25 @@ +# Feature Spec: Transaction Summary + +## Goal +Provide a summary of stored transactions. + +## Scope +- In: + - Aggregating transaction counts by decision + - Returning total transaction count +- Out: + - Modifying transactions + - Advanced analytics or historical trends + +## Requirements +- The system must provide a summary endpoint for transactions. +- The summary must include: + - total transaction count + - approved transaction count + - review transaction count + - rejected transaction count + +## Acceptance Criteria +- [X] User can retrieve transaction summary +- [X] Summary includes total, approved, review, and rejected counts +- [X] Summary values accurately reflect stored transaction data \ No newline at end of file diff --git a/TODO.md b/TODO.md index e8f77a59..52e68e38 100644 --- a/TODO.md +++ b/TODO.md @@ -5,7 +5,7 @@ ## Testing - [ ] Add tests for transaction summary - +- [ ] Add health check endpoint ## New Feature Proposals diff --git a/app/main.py b/app/main.py index dacf4e3e..aee3bb7d 100644 --- a/app/main.py +++ b/app/main.py @@ -3,6 +3,7 @@ from .db import Base, engine from . import models from .routes.transactions import router as transactions_router +from .schemas import HealthCheckResponse # Ensure SQLAlchemy models are registered before table creation. _ = models @@ -11,3 +12,8 @@ app = FastAPI(title="Transaction Review Service") app.include_router(transactions_router) + + +@app.get("/", response_model=HealthCheckResponse) +def health_check() -> HealthCheckResponse: + return {"message": "Transaction Review Service is running. See /docs for API usage."} diff --git a/app/schemas.py b/app/schemas.py index 451aedd5..1f2107af 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -26,3 +26,7 @@ class TransactionSummaryResponse(BaseModel): approved: int review: int rejected: int + + +class HealthCheckResponse(BaseModel): + message: str From 5c2d06b9bfbcef6043bef363dab1349dcefb7b7a Mon Sep 17 00:00:00 2001 From: nicolewiktor Date: Sun, 22 Mar 2026 17:23:16 -0400 Subject: [PATCH 13/15] clean up TODOs --- TODO.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/TODO.md b/TODO.md index 52e68e38..e69de29b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,12 +0,0 @@ -# TODO - -## Feature Implementation -- [ ] Implement transaction summary endpoint - -## Testing -- [ ] Add tests for transaction summary -- [ ] Add health check endpoint - -## New Feature Proposals - -- \ No newline at end of file From d9cb6ac88fd99116477ac90c55be2772714ab8ec Mon Sep 17 00:00:00 2001 From: nicolewiktor Date: Sun, 22 Mar 2026 18:46:09 -0400 Subject: [PATCH 14/15] docs: add README with usage, endpoints, and examples --- README.md | 173 ++++++++++++++++++++++++++++--------- tests/test_transactions.py | 8 ++ 2 files changed, 138 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 494f1c75..a4f59775 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,130 @@ -# Candidate Assessment: Spec-Driven Development With Codegen Tools - -This assessment evaluates how you use modern code generation tools (for example `5.2-Codex`, `Claude`, `Copilot`, and similar) to design, build, and test a software application using a spec-driven development pattern. You may build a frontend, a backend, or both. - -## Goals -- Build a working application with at least one meaningful feature. -- Create a testing framework to validate the application. -- Demonstrate effective use of code generation tools to accelerate delivery. -- Show clear, maintainable engineering practices. - -## Deliverables -- Application source code in this repository. -- A test suite and test harness that can be run locally. -- Documentation that explains how to run the app and the tests. - -## Scope Options -Pick one: -- Frontend-only application. -- Backend-only application. -- Full-stack application. - -Your solution should include at least one real workflow, for example: -- Create and view a resource. -- Search or filter data. -- Persist data in memory or storage. - -## Rules -- You must use a code generation tool (for example `5.2-Codex`, `Claude`, or similar). You can use multiple tools. -- You must build the application and a testing framework for it. -- The application and tests must run locally. -- Do not include secrets or credentials in this repository. - -## Evaluation Criteria -- Working product: Does the app do what it claims? -- Test coverage: Do tests cover key workflows and edge cases? -- Engineering quality: Clarity, structure, and maintainability. -- Use of codegen: How effectively you used tools to accelerate work. -- Documentation: Clear setup and run instructions. - -## What to Submit -- When you are complete, put up a Pull Request against this repository with your changes. -- A short summary of your approach and tools used in your PR submission -- Any additional information or approach that helped you. +# Transaction Review Service + +This is a backend service that accepts transaction requests and decides whether to approve, review, or reject them based on predefined business rules. + + +It was built using a spec-driven workflow, where each feature was defined before implementation and validated with tests. + +## What It Does +- Accepts transaction data through an API +- Applies deterministic rules to evaluate the risk of the transaction +- Stores transactions in a SQLite database +- Allows retrieval and filtering of transactions +- Provides a summary of decisions + +## Tech Stack +- FastAPI +- SQLAlchemy + SQLite +- Pydantic +- Pytest + TestClient + +## Run Locally +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +Use /docs to explore endpoints. + +API: `http://127.0.0.1:8000` +Docs: `http://127.0.0.1:8000/docs` + +SQLite database file is created at `transactions.db`. + +## Run Tests +```bash +pytest -q +``` + +## Main Endpoints +- `POST /transactions` - create and evaluate a transaction +- `GET /transactions` - list transactions (optional `?decision=approved|review|rejected`) +- `GET /transactions/{transaction_id}` - fetch one transaction by ID +- `GET /transactions/summary` - return aggregate counts by decision + +## Request Fields (`POST /transactions`) +- `account_id` (string) +- `amount` (number, must be `> 0`) +- `country` (string) +- `available_balance` (number) +- `account_status` (string) +- `transaction_type` (string) +- `new_payee` (boolean) + +Example request body with response of "review": +```json +{ + "account_id": "acct-123", + "amount": 1200.50, + "country": "US", + "available_balance": 5000.00, + "account_status": "active", + "transaction_type": "wire", + "new_payee": true +} +``` + +## How To Submit A Transaction +Send a `POST` request to `/transactions` with JSON body. + +Valid example: +```json +{ + "account_id": "acct-100", + "amount": 250.0, + "country": "US", + "available_balance": 1000.0, + "account_status": "active", + "transaction_type": "card", + "new_payee": false +} +``` + +Invalid example (`amount <= 0`): +```json +{ + "account_id": "acct-100", + "amount": 0, + "country": "US", + "available_balance": 1000.0, + "account_status": "active", + "transaction_type": "card", + "new_payee": false +} +``` + +Expected error response: +```json +{ + "detail": "amount must be greater than 0" +} +``` + +## Decision Rules + +### Rejected +- `account_status != "active"` +- `amount > available_balance` + +### Review (only if not rejected) +- `amount >= 5000` +- `country != "US"` +- `new_payee == true` and `amount >= 1000` +- `transaction_type == "wire"` and `amount >= 2000` + +### Approved +- no rules triggered + +## Response Shape +- `POST /transactions` and retrieval endpoints return: + - `id` (int) + - `decision` (`approved` | `review` | `rejected`) + - `reasons` (list of strings) + +## Error Behavior +- Invalid body/field types: `422` +- `amount <= 0`: `422` with `"amount must be greater than 0"` +- Invalid decision filter: `422` +- Unknown transaction ID: `404` with `"Transaction not found."` diff --git a/tests/test_transactions.py b/tests/test_transactions.py index 710128bf..16fc808b 100644 --- a/tests/test_transactions.py +++ b/tests/test_transactions.py @@ -16,6 +16,14 @@ def create_transaction(client, **overrides): return client.post("/transactions", json=build_payload(**overrides)) +def test_health_check_returns_service_message(client): + response = client.get("/") + assert response.status_code == 200 + body = response.json() + assert "message" in body + assert "running" in body["message"].lower() + + def test_valid_transaction_submission_succeeds(client): response = create_transaction(client) assert response.status_code == 201 From 3fdd14078f9600842fe3617b01860d4e80d4283b Mon Sep 17 00:00:00 2001 From: nicolewiktor Date: Sun, 22 Mar 2026 18:53:25 -0400 Subject: [PATCH 15/15] docs: clean up README --- README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/README.md b/README.md index a4f59775..eb9e5bea 100644 --- a/README.md +++ b/README.md @@ -53,18 +53,6 @@ pytest -q - `transaction_type` (string) - `new_payee` (boolean) -Example request body with response of "review": -```json -{ - "account_id": "acct-123", - "amount": 1200.50, - "country": "US", - "available_balance": 5000.00, - "account_status": "active", - "transaction_type": "wire", - "new_payee": true -} -``` ## How To Submit A Transaction Send a `POST` request to `/transactions` with JSON body.