Compare commits
1 Commits
master
...
payoff-tes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e196c5d26 |
@ -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}"
|
||||
|
||||
|
||||
@ -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
111
docs/architecture.md
Normal 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
127
tests/test_payoff.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user