Compare commits
No commits in common. "payoff-test" and "master" have entirely different histories.
payoff-tes
...
master
@ -1,8 +1,7 @@
|
|||||||
"""Database configuration and connection management."""
|
"""Database configuration and connection management."""
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import MetaData, create_engine, Column, String, Numeric, Date, DateTime
|
from sqlalchemy import MetaData, create_engine
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
@ -14,20 +13,6 @@ class Base(DeclarativeBase):
|
|||||||
metadata = MetaData()
|
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 construction
|
||||||
DATABASE_URL = f"oracle+oracledb://{settings.oracle_user}:{settings.oracle_password}@{settings.oracle_dsn}"
|
DATABASE_URL = f"oracle+oracledb://{settings.oracle_user}:{settings.oracle_password}@{settings.oracle_dsn}"
|
||||||
|
|
||||||
|
|||||||
@ -4,11 +4,9 @@ from decimal import Decimal
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, validator
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.database import get_db, PO10DAY
|
|
||||||
from app.logging import get_logger
|
from app.logging import get_logger
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -32,7 +30,7 @@ class PayoffRequest(BaseModel):
|
|||||||
payment_method: str = Field(..., description="Payment method", regex="^(check|wire|ach|cash)$")
|
payment_method: str = Field(..., description="Payment method", regex="^(check|wire|ach|cash)$")
|
||||||
notes: Optional[str] = Field(None, description="Additional notes", max_length=500)
|
notes: Optional[str] = Field(None, description="Additional notes", max_length=500)
|
||||||
|
|
||||||
@field_validator('payoff_date')
|
@validator('payoff_date')
|
||||||
def validate_payoff_date(cls, v):
|
def validate_payoff_date(cls, v):
|
||||||
"""Validate payoff date is not in the future."""
|
"""Validate payoff date is not in the future."""
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@ -50,47 +48,18 @@ class PayoffResponse(BaseModel):
|
|||||||
transaction_id: str
|
transaction_id: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/payoff", response_model=PayoffResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/payoff", response_model=PayoffResponse)
|
||||||
async def create_payoff(payoff_request: PayoffRequest, db: Session = Depends(get_db)) -> PayoffResponse:
|
async def create_payoff(payoff_request: PayoffRequest) -> PayoffResponse:
|
||||||
"""Create a loan payoff record."""
|
"""Create a loan payoff record."""
|
||||||
|
|
||||||
logger.info(f"Processing payoff for loan {payoff_request.loan_id}")
|
logger.info(f"Processing payoff for loan {payoff_request.loan_id}")
|
||||||
|
|
||||||
# Check if loan_id already exists
|
# TODO: Add database logic to process payoff
|
||||||
existing_payoff = db.query(PO10DAY).filter(PO10DAY.loan_id == payoff_request.loan_id).first()
|
# For now, return a mock response
|
||||||
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
|
import uuid
|
||||||
transaction_id = str(uuid.uuid4())
|
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(
|
response = PayoffResponse(
|
||||||
status=PayoffStatus.PROCESSED,
|
status=PayoffStatus.PROCESSED,
|
||||||
loan_id=payoff_request.loan_id,
|
loan_id=payoff_request.loan_id,
|
||||||
@ -99,4 +68,6 @@ async def create_payoff(payoff_request: PayoffRequest, db: Session = Depends(get
|
|||||||
transaction_id=transaction_id
|
transaction_id=transaction_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(f"Payoff processed successfully for loan {payoff_request.loan_id}, transaction {transaction_id}")
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
- 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
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
"""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