Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce Schemas to handle missing body, empty transaction list, missing fields, additional date formats #48

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ __pycache__/
.tox
*.env
actualtap_py.egg-info/

# API Testing Client
bruno/
16 changes: 6 additions & 10 deletions api/transactions.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
from decimal import Decimal
from typing import List
from typing import Union

from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException

from core.security import get_api_key
from models.transaction import Transaction
from schemas.transactions import Transaction
from services.actual_service import actual_service

router = APIRouter()


@router.post("/transactions", dependencies=[Depends(get_api_key)])
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed as /transaction and /transactions/ will work regardless, no need to add both.
Additionally we do not need the dependency on get_api_key here as it's already added in main.py when we add the route

@router.post("/transactions/", dependencies=[Depends(get_api_key)])
def add_transactions(transactions: Union[Transaction, List[Transaction]]):
@router.post("/transactions")
def add_transactions(transactions: List[Transaction]):
# check if there is a body
if not transactions:
raise HTTPException(status_code=400, detail="No transactions provided")
try:
if isinstance(transactions, Transaction):
transactions = [transactions]

for transaction in transactions:
transaction.amount *= Decimal(-1) # Invert the amount

Expand Down
10 changes: 0 additions & 10 deletions core/security.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import random
import string
import time

from fastapi import HTTPException
from fastapi import Security
from fastapi.security.api_key import APIKeyHeader
Expand All @@ -20,9 +16,3 @@ async def get_api_key(api_key_header: str = Security(api_key_header)):
status_code=403,
detail="Could not validate credentials",
)


def generate_custom_id():
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed as its only used in 1 function, and we can use the default python uuid library

timestamp = str(int(time.time()))
random_chars = "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
return f"ID-{timestamp}-{random_chars}"
28 changes: 21 additions & 7 deletions core/util.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
from datetime import date
from datetime import datetime
from typing import Union


def convert_to_date(date_str: str) -> date:
# Define the format of the input date string
date_format = "%b %d, %Y"
def convert_to_date(date_input: Union[str, datetime]) -> date:
if isinstance(date_input, datetime):
return date_input.date()

# Parse the date string into a datetime object
datetime_obj = datetime.strptime(date_str, date_format)
# Try different date formats
date_formats = [
"%Y-%m-%d", # 2024-11-25 (ISO format)
"%b %d, %Y", # Nov 25, 2024
"%b %d %Y", # Nov 25 2024
]

# Extract and return the date part
return datetime_obj.date()
for date_format in date_formats:
try:
datetime_obj = datetime.strptime(date_input, date_format)
return datetime_obj.date()
except ValueError:
continue

# If none of the formats worked, raise an error with examples
raise ValueError(
"Invalid date format. Accepted formats:\n" "- YYYY-MM-DD (e.g. 2024-11-25)\n" "- MMM DD, YYYY (e.g. Nov 25, 2024)\n"
)
34 changes: 30 additions & 4 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,33 @@ async def openapi():

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
logger.error(f"Validation error for request {request.method} {request.url}:\n{json.dumps(exc.errors(), indent=2)}")
logger.error(f"Request body: {json.dumps(await request.json(), indent=2)}")
errors = []
for error in exc.errors():
error_msg = error.get("msg", "")
if isinstance(error_msg, str) and "Invalid date format" in error_msg:
errors.append(
{
"loc": error.get("loc", []),
"msg": "Invalid date format. Accepted formats:\n- YYYY-MM-DD (e.g. 2024-11-25)\n- MMM DD, YYYY (e.g. Nov 25, 2024)\n- MMM DD YYYY (e.g. Nov 25 2024)",
"type": "value_error",
}
)
else:
errors.append(error)

logger.error(
f"Validation error for request {request.method} {request.url}:\n{json.dumps(errors, indent=2)}"
)
try:
body = await request.json()
logger.error(f"Request body: {json.dumps(body, indent=2)}")
except Exception as e:
logger.error(f"Error reading request body: {str(e)}")
body = None

return JSONResponse(
status_code=422,
content={"detail": exc.errors(), "body": await request.json()},
content={"detail": errors, "body": body},
)


Expand All @@ -70,7 +91,12 @@ async def http_exception_handler(request: Request, exc: HTTPException):
# Log the details of the bad request
if exc.status_code == 400 or exc.status_code == 500:
logger.error(f"Validation error for request {request.method} {request.url}:\n{json.dumps(exc.detail, indent=2)}")
logger.error(f"Request body: {json.dumps(await request.json(), indent=2)}")
try:
body = await request.json()
logger.error(f"Request body: {json.dumps(body, indent=2)}")
except Exception as e:
logger.error(f"Error reading request body: {str(e)}")
body = None

# Return the default HTTP exception response
return await default_http_exception_handler(request, exc)
22 changes: 0 additions & 22 deletions models/transaction.py

This file was deleted.

File renamed without changes.
39 changes: 39 additions & 0 deletions schemas/transactions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from datetime import date, datetime
from decimal import Decimal
from typing import Optional

from pydantic import BaseModel, Field, field_validator

from core.util import convert_to_date


class Transaction(BaseModel):
account: str = Field(..., description="Account name or ID is required")
amount: Decimal = Field(default=Decimal(0), description="Transaction amount")
date: datetime = Field(
default_factory=datetime.now,
description=(
"Transaction date in formats: YYYY-MM-DD, MMM DD, YYYY, or MMM DD YYYY"
),
)
payee: Optional[str] = None
notes: Optional[str] = None
cleared: bool = False

@field_validator("amount", mode="before")
def validate_amount(cls, v):
try:
return Decimal(str(v)) if v else Decimal(0)
except Exception:
raise ValueError("Invalid amount format. Must be a valid decimal number.")

@field_validator("date", mode="before")
def parse_date(cls, value):
try:
parsed_date = convert_to_date(value)
# If convert_to_date returns a date object, convert it to datetime
if isinstance(parsed_date, date) and not isinstance(parsed_date, datetime):
return datetime.combine(parsed_date, datetime.min.time())
return parsed_date
except ValueError as e:
raise ValueError(str(e))
77 changes: 49 additions & 28 deletions services/actual_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import uuid
from decimal import Decimal
from typing import List

Expand All @@ -8,9 +9,8 @@

from core.config import settings
from core.logs import MyLogger
from core.security import generate_custom_id
from core.util import convert_to_date
from models.transaction import Transaction
from schemas.transactions import Transaction

logger = MyLogger()

Expand All @@ -22,46 +22,67 @@ def __init__(self):
def add_transactions(self, transactions: List[Transaction]):
transaction_info_list = []
submitted_transactions = []
with Actual(settings.actual_url, password=settings.actual_password, file=settings.actual_budget) as actual:
for t in transactions:
actual_acount_id = settings.account_mappings.get(t.account, settings.actual_default_account_id)
date = convert_to_date(t.date)
import_id = generate_custom_id()

with Actual(
settings.actual_url,
password=settings.actual_password,
file=settings.actual_budget,
) as actual:
for tx in transactions:
# Map account name to Actual account ID
account_id = settings.account_mappings.get(
tx.account, settings.actual_default_account_id
)
if not account_id:
raise ValueError(
f"Account name '{tx.account}' is not mapped to an Actual Account ID."
)

# Convert date and generate import ID
date = convert_to_date(tx.date)
import_id = f"ID-{uuid.uuid4()}"

# Determine payee
payee = tx.payee or settings.actual_backup_payee

# Prepare transaction info for logging
transaction_info = {
"Account": t.account,
"Account_ID": actual_acount_id,
"Amount": str(Decimal(t.amount)),
"Account": tx.account,
"Account_ID": account_id,
"Amount": str(Decimal(tx.amount)),
"Date": str(date),
"Imported ID": import_id,
"Payee": t.payee,
"Notes": t.notes,
"Cleared": t.cleared,
"Payee": payee,
"Notes": tx.notes,
"Cleared": tx.cleared,
}
transaction_info_list.append(transaction_info)
# validate account_id
if not actual_acount_id:
raise ValueError(f"Account name '{t.account}' is not mapped to an Actual Account ID.")
if not t.payee:
payee = settings.actual_backup_payee
else:
payee = t.payee
t = create_transaction(

# Create transaction in Actual
actual_transaction = create_transaction(
s=actual.session,
account=actual_acount_id,
amount=Decimal(t.amount),
account=account_id,
amount=Decimal(tx.amount),
date=date,
imported_id=import_id,
payee=t.payee,
notes=t.notes,
cleared=t.cleared,
payee=payee,
notes=tx.notes,
cleared=tx.cleared,
imported_payee=payee,
)
submitted_transactions.append(t)
submitted_transactions.append(actual_transaction)

# Run ruleset on submitted transactions
rs = get_ruleset(actual.session)
rs.run(submitted_transactions)

# Log transaction info
logger.info("\n" + json.dumps(transaction_info_list, indent=2))

# Commit changes
actual.commit()
return transaction_info_list

return transaction_info_list


# Initialize the service
Expand Down