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/README.md b/README.md index 494f1c75..eb9e5bea 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,118 @@ -# 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) + + +## 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/SPECS/decision-evaluation.md b/SPECS/decision-evaluation.md new file mode 100644 index 00000000..a3920bcf --- /dev/null +++ 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 +- [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 new file mode 100644 index 00000000..e84142a5 --- /dev/null +++ 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 new file mode 100644 index 00000000..cb8e1e15 --- /dev/null +++ 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 new file mode 100644 index 00000000..8f089696 --- /dev/null +++ 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 new file mode 100644 index 00000000..f85a4008 --- /dev/null +++ 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 b5d82042..e69de29b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +0,0 @@ -# TODO - -## Refactor Proposals -- - -## New Feature Proposals -- \ No newline at end of file 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/main.py b/app/main.py new file mode 100644 index 00000000..aee3bb7d --- /dev/null +++ b/app/main.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI + +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 + +Base.metadata.create_all(bind=engine) + +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/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/routes/transactions.py b/app/routes/transactions.py new file mode 100644 index 00000000..6c901611 --- /dev/null +++ b/app/routes/transactions.py @@ -0,0 +1,87 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from ..db import get_db +from ..models import Transaction +from ..schemas import TransactionCreate, TransactionResponse, TransactionSummaryResponse +from ..services.decision_engine import evaluate_transaction + +router = APIRouter(tags=["transactions"]) +ALLOWED_DECISIONS = {"approved", "review", "rejected"} + + +@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=422, + detail="amount must be greater than 0", + ) + + decision, reasons = evaluate_transaction(payload) + + 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, + decision=decision, + reasons=reasons, + ) + db.add(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=422, + 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/summary", response_model=TransactionSummaryResponse) +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, + 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 diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 00000000..1f2107af --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,32 @@ +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) + + +class TransactionSummaryResponse(BaseModel): + total: int + approved: int + review: int + rejected: int + + +class HealthCheckResponse(BaseModel): + message: str diff --git a/app/services/decision_engine.py b/app/services/decision_engine.py new file mode 100644 index 00000000..b961ed33 --- /dev/null +++ b/app/services/decision_engine.py @@ -0,0 +1,29 @@ +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 > 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", [] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..b6e4ed7c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +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..c4601efc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,41 @@ +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 + + +@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..16fc808b --- /dev/null +++ b/tests/test_transactions.py @@ -0,0 +1,201 @@ +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_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 + 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_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): + 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=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): + 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, + } + +