write POST payoff to oracle and test it - WIP
This commit is contained in:
parent
35314f4f8b
commit
0e196c5d26
@ -1,7 +1,8 @@
|
|||||||
"""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
|
from sqlalchemy import MetaData, create_engine, Column, String, Numeric, Date, DateTime
|
||||||
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
|
||||||
|
|
||||||
@ -13,6 +14,20 @@ 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,9 +4,11 @@ from decimal import Decimal
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, Field, validator
|
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
|
from app.logging import get_logger
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -30,7 +32,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)
|
||||||
|
|
||||||
@validator('payoff_date')
|
@field_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
|
||||||
@ -48,18 +50,47 @@ class PayoffResponse(BaseModel):
|
|||||||
transaction_id: str
|
transaction_id: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/payoff", response_model=PayoffResponse)
|
@router.post("/payoff", response_model=PayoffResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_payoff(payoff_request: PayoffRequest) -> PayoffResponse:
|
async def create_payoff(payoff_request: PayoffRequest, db: Session = Depends(get_db)) -> 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}")
|
||||||
|
|
||||||
# TODO: Add database logic to process payoff
|
# Check if loan_id already exists
|
||||||
# For now, return a mock response
|
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
|
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,
|
||||||
@ -68,6 +99,4 @@ async def create_payoff(payoff_request: PayoffRequest) -> PayoffResponse:
|
|||||||
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
|
||||||
|
|||||||
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