write POST payoff to oracle and test it - WIP

This commit is contained in:
Timothy Farrell 2025-12-31 12:13:43 -06:00
parent 35314f4f8b
commit 0e196c5d26
4 changed files with 292 additions and 10 deletions

View File

@ -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}"

View File

@ -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

111
docs/architecture.md Normal file
View File

@ -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

127
tests/test_payoff.py Normal file
View File

@ -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()