""" 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