persona-finance-tracker-demo/app/schemas/transaction.py
2025-09-13 17:40:23 +05:30

219 lines
7.1 KiB
Python

"""
Transaction Marshmallow Schema
Validation and serialization schema for Transaction model.
Handles input validation, DECIMAL money precision, and JSON serialization.
"""
from marshmallow import Schema, fields, validate, validates, ValidationError, post_load
from decimal import Decimal, InvalidOperation
from datetime import date
class DecimalField(fields.Field):
"""Custom Decimal field for precise money handling."""
def _serialize(self, value, attr, obj, **kwargs):
"""Convert Decimal to float for JSON serialization."""
if value is None:
return None
return float(value)
def _deserialize(self, value, attr, data, **kwargs):
"""Convert input to Decimal with validation."""
if value is None:
return None
try:
# Convert to Decimal and validate precision
decimal_value = Decimal(str(value))
# Check for more than 2 decimal places
exponent = decimal_value.as_tuple().exponent
if isinstance(exponent, int) and exponent < -2:
raise ValidationError("Amount cannot have more than 2 decimal places")
# Check for reasonable range (max 99,999,999.99)
if decimal_value > Decimal('99999999.99'):
raise ValidationError("Amount cannot exceed $99,999,999.99")
if decimal_value <= 0:
raise ValidationError("Amount must be greater than 0")
return decimal_value
except (InvalidOperation, ValueError, TypeError):
raise ValidationError("Invalid amount format")
class TransactionSchema(Schema):
"""
Schema for Transaction model validation and serialization.
Provides input validation for:
- Amount: DECIMAL precision with 2 decimal places max
- Type: Enum validation (income/expense)
- Description: Required text field
- Category: Optional foreign key relationship
- Date: Transaction date validation
"""
# Fields with validation
id = fields.Integer(dump_only=True) # Read-only field
description = fields.String(
required=True,
validate=[
validate.Length(min=1, max=255, error="Description must be 1-255 characters"),
validate.Regexp(
r'^[a-zA-Z0-9\s\-_.,!@#$%^&*()+=\[\]{}|;:\'\"<>?/~`]+$',
error="Description contains invalid characters"
)
]
)
amount = DecimalField(required=True)
type = fields.String(
required=True,
validate=validate.OneOf(
['income', 'expense'],
error="Type must be either 'income' or 'expense'"
)
)
category_id = fields.Integer(
allow_none=True,
validate=validate.Range(min=1, error="Category ID must be a positive integer")
)
transaction_date = fields.Date(
required=True,
format='%Y-%m-%d',
error_messages={'invalid': 'Date must be in YYYY-MM-DD format'}
)
notes = fields.String(
allow_none=True,
validate=validate.Length(max=1000, error="Notes cannot exceed 1000 characters")
)
created_at = fields.DateTime(dump_only=True, format='iso')
# Nested category information for read operations
category_name = fields.String(dump_only=True)
@validates('transaction_date')
def validate_transaction_date(self, value, **kwargs):
"""Validate transaction date is not in the future."""
if value and value > date.today():
raise ValidationError("Transaction date cannot be in the future")
@post_load
def process_data(self, data, **kwargs):
"""Post-process validated data."""
# Set default transaction_date if not provided
if 'transaction_date' not in data:
data['transaction_date'] = date.today()
return data
class Meta:
"""Schema configuration."""
ordered = True # Preserve field order in output
class TransactionCreateSchema(TransactionSchema):
"""Schema for creating new transactions."""
class Meta:
"""Schema configuration for creation."""
exclude = ('id', 'created_at', 'category_name')
ordered = True
class TransactionUpdateSchema(TransactionSchema):
"""Schema for updating existing transactions."""
# Make all fields optional for partial updates
description = fields.String(
required=False,
validate=[
validate.Length(min=1, max=255, error="Description must be 1-255 characters"),
validate.Regexp(
r'^[a-zA-Z0-9\s\-_.,!@#$%^&*()+=\[\]{}|;:\'\"<>?/~`]+$',
error="Description contains invalid characters"
)
]
)
amount = DecimalField(required=False)
type = fields.String(
required=False,
validate=validate.OneOf(
['income', 'expense'],
error="Type must be either 'income' or 'expense'"
)
)
transaction_date = fields.Date(
required=False,
format='%Y-%m-%d',
error_messages={'invalid': 'Date must be in YYYY-MM-DD format'}
)
class Meta:
"""Schema configuration for updates."""
exclude = ('id', 'created_at', 'category_name')
ordered = True
class TransactionListSchema(Schema):
"""Schema for transaction list with category details."""
id = fields.Integer()
description = fields.String()
amount = DecimalField()
type = fields.String()
category_id = fields.Integer(allow_none=True)
category_name = fields.String(allow_none=True)
transaction_date = fields.Date(format='%Y-%m-%d')
notes = fields.String(allow_none=True)
created_at = fields.DateTime(format='iso')
class Meta:
"""Schema configuration."""
ordered = True
class TransactionFilterSchema(Schema):
"""Schema for transaction filtering parameters."""
type = fields.String(
validate=validate.OneOf(['income', 'expense']),
allow_none=True
)
category_id = fields.Integer(
validate=validate.Range(min=1),
allow_none=True
)
start_date = fields.Date(
format='%Y-%m-%d',
allow_none=True,
error_messages={'invalid': 'Start date must be in YYYY-MM-DD format'}
)
end_date = fields.Date(
format='%Y-%m-%d',
allow_none=True,
error_messages={'invalid': 'End date must be in YYYY-MM-DD format'}
)
page = fields.Integer(
validate=validate.Range(min=1),
load_default=1
)
per_page = fields.Integer(
validate=validate.Range(min=1, max=100),
load_default=20
)
@validates('end_date')
def validate_date_range(self, value):
"""Validate end_date is not before start_date."""
if value and 'start_date' in self.context:
start_date = self.context.get('start_date')
if start_date and value < start_date:
raise ValidationError("End date cannot be before start date")
class Meta:
"""Schema configuration."""
ordered = True