From 0e196c5d26fbec1097be9328e6b4ddc03cecda8b Mon Sep 17 00:00:00 2001 From: Timothy Farrell Date: Wed, 31 Dec 2025 12:13:43 -0600 Subject: [PATCH] write POST payoff to oracle and test it - WIP --- app/database.py | 17 +++++- app/resources/payoff.py | 47 ++++++++++++--- docs/architecture.md | 111 +++++++++++++++++++++++++++++++++++ tests/test_payoff.py | 127 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 docs/architecture.md create mode 100644 tests/test_payoff.py diff --git a/app/database.py b/app/database.py index 465171e..09e7eb1 100644 --- a/app/database.py +++ b/app/database.py @@ -1,7 +1,8 @@ """Database configuration and connection management.""" from typing import Generator +from datetime import datetime -from sqlalchemy import MetaData, create_engine +from sqlalchemy import MetaData, create_engine, Column, String, Numeric, Date, DateTime from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker @@ -13,6 +14,20 @@ class Base(DeclarativeBase): metadata = MetaData() +class PO10DAY(Base): + """PO10DAY Oracle table model.""" + __tablename__ = 'PO10DAY' + + loan_id = Column(String(50), primary_key=True) + payoff_amount = Column(Numeric(15, 2), nullable=False) + payoff_date = Column(Date, nullable=False) + borrower_name = Column(String(100), nullable=True) + payment_method = Column(String(20), nullable=False) + notes = Column(String(500), nullable=True) + transaction_id = Column(String(36), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + # Database URL construction DATABASE_URL = f"oracle+oracledb://{settings.oracle_user}:{settings.oracle_password}@{settings.oracle_dsn}" diff --git a/app/resources/payoff.py b/app/resources/payoff.py index dba5c70..284564f 100644 --- a/app/resources/payoff.py +++ b/app/resources/payoff.py @@ -4,9 +4,11 @@ from decimal import Decimal from enum import Enum from typing import Optional -from fastapi import APIRouter -from pydantic import BaseModel, Field, validator +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field, field_validator +from sqlalchemy.orm import Session +from app.database import get_db, PO10DAY from app.logging import get_logger router = APIRouter() @@ -30,7 +32,7 @@ class PayoffRequest(BaseModel): payment_method: str = Field(..., description="Payment method", regex="^(check|wire|ach|cash)$") notes: Optional[str] = Field(None, description="Additional notes", max_length=500) - @validator('payoff_date') + @field_validator('payoff_date') def validate_payoff_date(cls, v): """Validate payoff date is not in the future.""" from datetime import date @@ -48,18 +50,47 @@ class PayoffResponse(BaseModel): transaction_id: str -@router.post("/payoff", response_model=PayoffResponse) -async def create_payoff(payoff_request: PayoffRequest) -> PayoffResponse: +@router.post("/payoff", response_model=PayoffResponse, status_code=status.HTTP_201_CREATED) +async def create_payoff(payoff_request: PayoffRequest, db: Session = Depends(get_db)) -> PayoffResponse: """Create a loan payoff record.""" logger.info(f"Processing payoff for loan {payoff_request.loan_id}") - # TODO: Add database logic to process payoff - # For now, return a mock response + # Check if loan_id already exists + existing_payoff = db.query(PO10DAY).filter(PO10DAY.loan_id == payoff_request.loan_id).first() + if existing_payoff: + logger.error(f"Payoff already exists for loan {payoff_request.loan_id}") + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Payoff already exists for loan {payoff_request.loan_id}" + ) import uuid transaction_id = str(uuid.uuid4()) + # Create database entry + try: + po10day_record = PO10DAY( + loan_id=payoff_request.loan_id, + payoff_amount=payoff_request.payoff_amount, + payoff_date=payoff_request.payoff_date, + borrower_name=payoff_request.borrower_name, + payment_method=payoff_request.payment_method, + notes=payoff_request.notes, + transaction_id=transaction_id + ) + + db.add(po10day_record) + db.commit() + db.refresh(po10day_record) + + logger.info(f"Payoff created successfully for loan {payoff_request.loan_id}, transaction {transaction_id}") + + except Exception as e: + db.rollback() + logger.error(f"Failed to create payoff for loan {payoff_request.loan_id}", exc_info=e) + raise e + response = PayoffResponse( status=PayoffStatus.PROCESSED, loan_id=payoff_request.loan_id, @@ -68,6 +99,4 @@ async def create_payoff(payoff_request: PayoffRequest) -> PayoffResponse: transaction_id=transaction_id ) - logger.info(f"Payoff processed successfully for loan {payoff_request.loan_id}, transaction {transaction_id}") - return response diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a13c88d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,111 @@ +- FastAPI +    - because: +        - more flexible deployment environments +        - less magic to the code +        - higher throughput +        ++ logging to Datadog ++ tracing with Sentry +- lint python code with pylint, autopep8 ++ check python types with mypy ++ code should be well type-hinted +- specify a stub unittest using the pytest library ++ Oracle DB access with the "oracledb" python module ++ a health-check endpoint +- rate limiting +- an inital POST /api/v1/resource - stub this for now +- ORM (need to pick one) +    - sqlalchemy +    +- audit log of all DB changes (POST/PUT/PATCH/DELETE), use middleware +    - do we need to log access (GET requests)? +    - Where to store audit records? +        - oracle +        - dynamo? +            - Pro: +                - fast, won't block or load oracle +                - cheap +            - Cons: +                - limited indexing +                - not blessed by SBA (yet) +                - have to work with Allocore +- LIST endpoints +    - support limited filtering +        - based on: +            - user +            - indexed columns +    - pagination + + +- Authentication +    - actors are: +        - API server (fastapi server) +        - client (app server) +        - user (browser) +    - request process +        - User connect to client to perform action (browser call) +        - Client requests changes to API server (s2s call) +    +    - "Browser call" authentication +        - client authenticates user via ULP/Okta +        - browser holds session cookie +    - "s2s" call +        - client holds PSK (api-key) for that specific service (prepay, LX, Origination, etc.) +        - client passes JWT which contains: +            - PSK +            - OIDC token +                - until Okta exists, just trust the client properly verifies with ULP +                - or forward session id and check with ULP +            - request_context (defined by the endpoint): +                - user_id? +                - location_id? +    + +non-architecture matters: +- package management with uv +- dockerize +- ci/cd github actions + codebuild +- pre-commit +    - lint +    - unittest +    - mypy? +- only skilled devs work on API server +- PR should cross-teams (2 reviewers) + + +Resources that we need in the near-term: +- POST Payoff +- GET Location +    - list locations user can access +- GET Users +    - lookup by id +    - search by location: /api/v1/user/?location-id= +- GET countries +- GET holidays +- GET franchises +- GET naics-code + + + +    +    + + +Open questions: +- query param OR convention (python-filterparams?) GraphQL? +-  +- Unified models for reads and writes +- API endpoint must match the structure of the models + + +Current issues: +- Dev environment + - add static checking + - autopep8 + - isort + - mypy +- Deploy environment +- add datadog logging +- add sentry error tracking +- authentication core +- add unittest infrastructure diff --git a/tests/test_payoff.py b/tests/test_payoff.py new file mode 100644 index 0000000..6ec0208 --- /dev/null +++ b/tests/test_payoff.py @@ -0,0 +1,127 @@ +"""Tests for payoff endpoints.""" +from datetime import date, timedelta +from decimal import Decimal +from unittest.mock import patch, MagicMock +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from main import create_app +from app.database import PO10DAY +from app.resources.payoff import PayoffStatus + + +class TestPayoffEndpoint: + """Test cases for payoff endpoint.""" + + @pytest.fixture + def client(self) -> TestClient: + """Create test client.""" + app = create_app() + return TestClient(app) + + @pytest.fixture + def valid_payoff_data(self) -> dict: + """Valid payoff request data.""" + return { + "loan_id": "LOAN123", + "payoff_amount": "10000.50", + "payoff_date": str(date.today()), + "borrower_name": "John Doe", + "payment_method": "check", + "notes": "Test payoff" + } + + @patch('app.resources.payoff.get_db') + def test_create_payoff_success(self, mock_get_db, client, valid_payoff_data): + """Test successful payoff creation.""" + # Mock database session + mock_db = MagicMock(spec=Session) + mock_get_db.return_value = mock_db + + # Mock no existing payoff + mock_db.query.return_value.filter.return_value.first.return_value = None + + response = client.post("/payoff", json=valid_payoff_data) + + assert response.status_code == 201 + data = response.json() + assert data["status"] == PayoffStatus.PROCESSED + assert data["loan_id"] == "LOAN123" + assert data["payoff_amount"] == "10000.50" + assert "transaction_id" in data + + # Verify database operations + mock_db.add.assert_called_once() + mock_db.commit.assert_called_once() + mock_db.refresh.assert_called_once() + + @patch('app.resources.payoff.get_db') + def test_create_payoff_duplicate_loan_id(self, mock_get_db, client, valid_payoff_data): + """Test payoff creation with duplicate loan_id.""" + # Mock database session + mock_db = MagicMock(spec=Session) + mock_get_db.return_value = mock_db + + # Mock existing payoff + existing_payoff = MagicMock(spec=PO10DAY) + mock_db.query.return_value.filter.return_value.first.return_value = existing_payoff + + response = client.post("/payoff", json=valid_payoff_data) + + assert response.status_code == 409 + assert "already exists" in response.json()["detail"] + + def test_create_payoff_invalid_payment_method(self, client, valid_payoff_data): + """Test payoff creation with invalid payment method.""" + valid_payoff_data["payment_method"] = "invalid_method" + + response = client.post("/payoff", json=valid_payoff_data) + + assert response.status_code == 422 + + def test_create_payoff_future_date(self, client, valid_payoff_data): + """Test payoff creation with future payoff date.""" + future_date = date.today() + timedelta(days=1) + valid_payoff_data["payoff_date"] = str(future_date) + + response = client.post("/payoff", json=valid_payoff_data) + + assert response.status_code == 422 + + def test_create_payoff_negative_amount(self, client, valid_payoff_data): + """Test payoff creation with negative amount.""" + valid_payoff_data["payoff_amount"] = "-1000.00" + + response = client.post("/payoff", json=valid_payoff_data) + + assert response.status_code == 422 + + def test_create_payoff_missing_required_fields(self, client): + """Test payoff creation with missing required fields.""" + incomplete_data = { + "loan_id": "LOAN123" + # Missing required fields + } + + response = client.post("/payoff", json=incomplete_data) + + assert response.status_code == 422 + + @patch('app.resources.payoff.get_db') + def test_create_payoff_database_error(self, mock_get_db, client, valid_payoff_data): + """Test payoff creation with database error.""" + # Mock database session + mock_db = MagicMock(spec=Session) + mock_get_db.return_value = mock_db + + # Mock no existing payoff + mock_db.query.return_value.filter.return_value.first.return_value = None + + # Mock database error on commit + mock_db.commit.side_effect = Exception("Database error") + + with pytest.raises(Exception): + response = client.post("/payoff", json=valid_payoff_data) + + mock_db.rollback.assert_called_once()