Skip to content

Finance API Reference

Technical documentation of the models, views, and services in the Finance application.

finance.models

Models for the finance application.

Defines core financial entities such as Categories, Transactions, Budgets, and Savings Goals. Includes logic for tracking spending relative to budget limits.

Budget

Bases: Model

Defines a user's total spending allowance for a specific month.

Attributes:

Name Type Description
user ForeignKey

The budget owner.

name CharField

Descriptive name (e.g., 'May 2026 Budget').

month int

1-12.

year int

YYYY.

total_limit Decimal

The maximum total spending allowed.

status str

Current standing (Active, Exceeded, Completed).

Source code in finance/models.py
class Budget(models.Model):
    """
    Defines a user's total spending allowance for a specific month.

    Attributes:
        user (ForeignKey): The budget owner.
        name (CharField): Descriptive name (e.g., 'May 2026 Budget').
        month (int): 1-12.
        year (int): YYYY.
        total_limit (Decimal): The maximum total spending allowed.
        status (str): Current standing (Active, Exceeded, Completed).
    """

    STATUS_ACTIVE = 'active'
    STATUS_EXCEEDED = 'exceeded'
    STATUS_COMPLETED = 'completed'
    STATUS_CHOICES = [
        (STATUS_ACTIVE, 'Active'),
        (STATUS_EXCEEDED, 'Exceeded'),
        (STATUS_COMPLETED, 'Completed'),
    ]

    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='budgets',
    )
    name = models.CharField(max_length=100)
    month = models.PositiveSmallIntegerField()
    year = models.PositiveIntegerField()
    total_limit = models.DecimalField(max_digits=14, decimal_places=2)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_ACTIVE)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        """Metadata for the Budget model."""
        ordering = ['-year', '-month']
        constraints = [
            models.UniqueConstraint(
                fields=['user', 'month', 'year'],
                name='unique_user_budget_period',
            )
        ]

    def __str__(self):
        """Return the budget name and period."""
        return f'{self.name} {self.month}/{self.year}'

    @property
    def spent(self):
        """
        Calculate total expenses for this budget's month and year.

        Returns:
            Decimal: Sum of all expenses in the matching period.
        """
        total = self.user.transactions.filter(
            type=Transaction.TYPE_EXPENSE,
            date__year=self.year,
            date__month=self.month,
        ).aggregate(total=Sum('amount'))['total']
        return total or 0

    def update_status(self):
        """
        Evaluate and update the budget status based on current spending.

        Compares total spent against total_limit and persists the status field.
        """
        spent = self.spent
        if spent > self.total_limit:
            self.status = self.STATUS_EXCEEDED
        elif spent == self.total_limit:
            self.status = self.STATUS_COMPLETED
        else:
            self.status = self.STATUS_ACTIVE
        self.save(update_fields=['status'])

spent property

Calculate total expenses for this budget's month and year.

Returns:

Name Type Description
Decimal

Sum of all expenses in the matching period.

Meta

Metadata for the Budget model.

Source code in finance/models.py
class Meta:
    """Metadata for the Budget model."""
    ordering = ['-year', '-month']
    constraints = [
        models.UniqueConstraint(
            fields=['user', 'month', 'year'],
            name='unique_user_budget_period',
        )
    ]

__str__()

Return the budget name and period.

Source code in finance/models.py
def __str__(self):
    """Return the budget name and period."""
    return f'{self.name} {self.month}/{self.year}'

update_status()

Evaluate and update the budget status based on current spending.

Compares total spent against total_limit and persists the status field.

Source code in finance/models.py
def update_status(self):
    """
    Evaluate and update the budget status based on current spending.

    Compares total spent against total_limit and persists the status field.
    """
    spent = self.spent
    if spent > self.total_limit:
        self.status = self.STATUS_EXCEEDED
    elif spent == self.total_limit:
        self.status = self.STATUS_COMPLETED
    else:
        self.status = self.STATUS_ACTIVE
    self.save(update_fields=['status'])

BudgetCategoryLimit

Bases: Model

Sets a specific spending cap for a single category within a larger budget.

Attributes:

Name Type Description
budget ForeignKey

Parent budget.

category ForeignKey

The category to limit.

limit Decimal

The capped amount for this category.

spent Decimal

Running total of expenses in this category.

status str

Standing (Active, Close, Exceeded).

Source code in finance/models.py
class BudgetCategoryLimit(models.Model):
    """
    Sets a specific spending cap for a single category within a larger budget.

    Attributes:
        budget (ForeignKey): Parent budget.
        category (ForeignKey): The category to limit.
        limit (Decimal): The capped amount for this category.
        spent (Decimal): Running total of expenses in this category.
        status (str): Standing (Active, Close, Exceeded).
    """

    STATUS_ACTIVE = 'active'
    STATUS_CLOSE = 'close'
    STATUS_EXCEEDED = 'exceeded'
    STATUS_CHOICES = [
        (STATUS_ACTIVE, 'Active'),
        (STATUS_CLOSE, 'Close'),
        (STATUS_EXCEEDED, 'Exceeded'),
    ]

    budget = models.ForeignKey(Budget, on_delete=models.CASCADE, related_name='category_limits')
    category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='budget_limits')
    limit = models.DecimalField(max_digits=14, decimal_places=2)
    spent = models.DecimalField(max_digits=14, decimal_places=2, default=0)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_ACTIVE)

    class Meta:
        """Metadata for the BudgetCategoryLimit model."""
        ordering = ['category__name']
        constraints = [
            models.UniqueConstraint(
                fields=['budget', 'category'],
                name='unique_budget_category_limit',
            )
        ]

    @property
    def remaining(self):
        """
        Calculate available funds remaining for this category.

        Returns:
            Decimal: Difference between limit and spent.
        """
        return max(self.limit - self.spent, 0)

    def update_status(self):
        """
        Evaluate status based on consumption percentage.

        'Close' status is triggered at 90% consumption.
        """
        if self.spent > self.limit:
            self.status = self.STATUS_EXCEEDED
        elif self.spent >= self.limit * Decimal('0.9'):
            self.status = self.STATUS_CLOSE
        else:
            self.status = self.STATUS_ACTIVE
        self.save(update_fields=['status'])

    def add_spent(self, amount):
        """
        Increase the spent tally and refresh the status.

        Args:
            amount (Decimal): Value to add.
        """
        self.spent += amount
        self.save(update_fields=['spent'])
        self.update_status()

remaining property

Calculate available funds remaining for this category.

Returns:

Name Type Description
Decimal

Difference between limit and spent.

Meta

Metadata for the BudgetCategoryLimit model.

Source code in finance/models.py
class Meta:
    """Metadata for the BudgetCategoryLimit model."""
    ordering = ['category__name']
    constraints = [
        models.UniqueConstraint(
            fields=['budget', 'category'],
            name='unique_budget_category_limit',
        )
    ]

add_spent(amount)

Increase the spent tally and refresh the status.

Parameters:

Name Type Description Default
amount Decimal

Value to add.

required
Source code in finance/models.py
def add_spent(self, amount):
    """
    Increase the spent tally and refresh the status.

    Args:
        amount (Decimal): Value to add.
    """
    self.spent += amount
    self.save(update_fields=['spent'])
    self.update_status()

update_status()

Evaluate status based on consumption percentage.

'Close' status is triggered at 90% consumption.

Source code in finance/models.py
def update_status(self):
    """
    Evaluate status based on consumption percentage.

    'Close' status is triggered at 90% consumption.
    """
    if self.spent > self.limit:
        self.status = self.STATUS_EXCEEDED
    elif self.spent >= self.limit * Decimal('0.9'):
        self.status = self.STATUS_CLOSE
    else:
        self.status = self.STATUS_ACTIVE
    self.save(update_fields=['status'])

Category

Bases: Model

Represents a classification for transactions (e.g., Food, Salary, Rent).

Categories can be system-predefined or custom-created by users.

Attributes:

Name Type Description
user ForeignKey

The user who created the category (None for predefined).

name CharField

The display name of the category.

type CharField

Choice of 'expense' or 'income'.

is_predefined BooleanField

True if the category is available to all users.

parent ForeignKey

Optional self-referential link for sub-categories.

created_at DateTimeField

When the category was added.

Source code in finance/models.py
class Category(models.Model):
    """
    Represents a classification for transactions (e.g., Food, Salary, Rent).

    Categories can be system-predefined or custom-created by users.

    Attributes:
        user (ForeignKey): The user who created the category (None for predefined).
        name (CharField): The display name of the category.
        type (CharField): Choice of 'expense' or 'income'.
        is_predefined (BooleanField): True if the category is available to all users.
        parent (ForeignKey): Optional self-referential link for sub-categories.
        created_at (DateTimeField): When the category was added.
    """

    TYPE_EXPENSE = 'expense'
    TYPE_INCOME = 'income'
    TYPE_CHOICES = [
        (TYPE_EXPENSE, 'Expense'),
        (TYPE_INCOME, 'Income'),
    ]

    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        null=True,
        blank=True,
        related_name='categories',
    )
    name = models.CharField(max_length=100)
    type = models.CharField(max_length=20, choices=TYPE_CHOICES)
    is_predefined = models.BooleanField(default=False)
    parent = models.ForeignKey(
        'self',
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name='children',
    )
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        """Metadata for the Category model."""
        ordering = ['type', 'name']
        constraints = [
            models.UniqueConstraint(
                fields=['user', 'name', 'type'],
                name='unique_user_category',
            )
        ]

    def __str__(self):
        """Return the category name."""
        return self.name

Meta

Metadata for the Category model.

Source code in finance/models.py
class Meta:
    """Metadata for the Category model."""
    ordering = ['type', 'name']
    constraints = [
        models.UniqueConstraint(
            fields=['user', 'name', 'type'],
            name='unique_user_category',
        )
    ]

__str__()

Return the category name.

Source code in finance/models.py
def __str__(self):
    """Return the category name."""
    return self.name

SavingsGoal

Bases: Model

Defines a long-term savings target.

Attributes:

Name Type Description
user ForeignKey

Goal owner.

name CharField

Name of the goal (e.g., 'New Car').

target_amount Decimal

Total money required.

current_amount Decimal

Money saved so far.

deadline DateField

Target date for completion.

completed BooleanField

Completion status.

Source code in finance/models.py
class SavingsGoal(models.Model):
    """
    Defines a long-term savings target.

    Attributes:
        user (ForeignKey): Goal owner.
        name (CharField): Name of the goal (e.g., 'New Car').
        target_amount (Decimal): Total money required.
        current_amount (Decimal): Money saved so far.
        deadline (DateField): Target date for completion.
        completed (BooleanField): Completion status.
    """

    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='savings_goals',
    )
    name = models.CharField(max_length=100)
    target_amount = models.DecimalField(max_digits=14, decimal_places=2)
    current_amount = models.DecimalField(max_digits=14, decimal_places=2, default=0)
    deadline = models.DateField(null=True, blank=True)
    completed = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        """Metadata for the SavingsGoal model."""
        ordering = ['-created_at']

    @property
    def progress(self):
        """
        Calculate the percentage towards the goal.

        Returns:
            int: Progress percentage (0-100).
        """
        if self.target_amount <= 0:
            return 0
        return min(100, int((self.current_amount / self.target_amount) * 100))

    def add_contribution(self, amount):
        """
        Log a contribution and check for goal completion.

        Args:
            amount (Decimal): Contribution value.
        """
        self.current_amount += amount
        if self.current_amount >= self.target_amount:
            self.completed = True
        self.save(update_fields=['current_amount', 'completed'])

progress property

Calculate the percentage towards the goal.

Returns:

Name Type Description
int

Progress percentage (0-100).

Meta

Metadata for the SavingsGoal model.

Source code in finance/models.py
class Meta:
    """Metadata for the SavingsGoal model."""
    ordering = ['-created_at']

add_contribution(amount)

Log a contribution and check for goal completion.

Parameters:

Name Type Description Default
amount Decimal

Contribution value.

required
Source code in finance/models.py
def add_contribution(self, amount):
    """
    Log a contribution and check for goal completion.

    Args:
        amount (Decimal): Contribution value.
    """
    self.current_amount += amount
    if self.current_amount >= self.target_amount:
        self.completed = True
    self.save(update_fields=['current_amount', 'completed'])

Transaction

Bases: Model

Represents an individual financial movement (income or expense).

Attributes:

Name Type Description
user ForeignKey

The user who owns the transaction.

type CharField

Choice of 'expense' or 'income'.

category ForeignKey

The category assigned to the transaction.

amount DecimalField

The monetary value.

date DateField

When the transaction occurred.

description CharField

A brief overview.

notes TextField

Optional detailed commentary.

source CharField

The origin of income (e.g., 'Employer Name').

created_at DateTimeField

Audit timestamp.

Source code in finance/models.py
class Transaction(models.Model):
    """
    Represents an individual financial movement (income or expense).

    Attributes:
        user (ForeignKey): The user who owns the transaction.
        type (CharField): Choice of 'expense' or 'income'.
        category (ForeignKey): The category assigned to the transaction.
        amount (DecimalField): The monetary value.
        date (DateField): When the transaction occurred.
        description (CharField): A brief overview.
        notes (TextField): Optional detailed commentary.
        source (CharField): The origin of income (e.g., 'Employer Name').
        created_at (DateTimeField): Audit timestamp.
    """

    TYPE_EXPENSE = 'expense'
    TYPE_INCOME = 'income'
    TYPE_CHOICES = [
        (TYPE_EXPENSE, 'Expense'),
        (TYPE_INCOME, 'Income'),
    ]

    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='transactions',
    )
    type = models.CharField(max_length=20, choices=TYPE_CHOICES)
    category = models.ForeignKey(
        Category,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name='transactions',
    )
    amount = models.DecimalField(max_digits=14, decimal_places=2)
    date = models.DateField(default=timezone.now)
    description = models.CharField(max_length=255, blank=True)
    notes = models.TextField(blank=True)
    source = models.CharField(max_length=100, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        """Metadata for the Transaction model."""
        ordering = ['-date', '-created_at']

    def __str__(self):
        """Return a formatted summary of the transaction."""
        return f'{self.type} {self.amount}'

Meta

Metadata for the Transaction model.

Source code in finance/models.py
class Meta:
    """Metadata for the Transaction model."""
    ordering = ['-date', '-created_at']

__str__()

Return a formatted summary of the transaction.

Source code in finance/models.py
def __str__(self):
    """Return a formatted summary of the transaction."""
    return f'{self.type} {self.amount}'

finance.serializers

Serializers for the finance application.

Handles validation and data conversion for categories, transactions, budgets, and savings goals. Includes business logic for linking transactions to the current user and service layer.

BudgetCategoryLimitSerializer

Bases: ModelSerializer

Serializer for assigning limits to specific categories within a budget.

Source code in finance/serializers.py
class BudgetCategoryLimitSerializer(serializers.ModelSerializer):
    """
    Serializer for assigning limits to specific categories within a budget.
    """

    remaining = serializers.DecimalField(max_digits=14, decimal_places=2, read_only=True)
    category_name = serializers.CharField(source='category.name', read_only=True)

    class Meta:
        """Metadata for BudgetCategoryLimitSerializer."""
        model = BudgetCategoryLimit
        fields = ['id', 'budget', 'category', 'category_name', 'limit', 'spent', 'remaining', 'status']
        read_only_fields = ['spent', 'status', 'remaining', 'category_name']

Meta

Metadata for BudgetCategoryLimitSerializer.

Source code in finance/serializers.py
class Meta:
    """Metadata for BudgetCategoryLimitSerializer."""
    model = BudgetCategoryLimit
    fields = ['id', 'budget', 'category', 'category_name', 'limit', 'spent', 'remaining', 'status']
    read_only_fields = ['spent', 'status', 'remaining', 'category_name']

BudgetSerializer

Bases: ModelSerializer

Serializer for monthly budgets.

Computes real-time 'spent' and 'remaining' values based on transaction history.

Source code in finance/serializers.py
class BudgetSerializer(serializers.ModelSerializer):
    """
    Serializer for monthly budgets.

    Computes real-time 'spent' and 'remaining' values based on transaction history.
    """

    spent = serializers.DecimalField(max_digits=14, decimal_places=2, read_only=True)
    remaining = serializers.SerializerMethodField()

    class Meta:
        """Metadata for BudgetSerializer."""
        model = Budget
        fields = [
            'id', 'name', 'month', 'year', 'total_limit',
            'spent', 'remaining', 'status', 'created_at', 'updated_at',
        ]
        read_only_fields = ['status', 'created_at', 'updated_at', 'spent', 'remaining']

    def get_remaining(self, obj):
        """
        Calculate available budget balance.

        Args:
            obj (Budget): The budget instance.

        Returns:
            Decimal: remaining amount (total_limit - spent).
        """
        remaining = obj.total_limit - obj.spent
        return max(remaining, 0)

    def create(self, validated_data):
        """
        Establish a new budget for the authenticated user.

        Args:
            validated_data (dict): Validated input data.

        Returns:
            Budget: Newly created budget instance.
        """
        validated_data['user'] = self.context['request'].user
        return super().create(validated_data)

Meta

Metadata for BudgetSerializer.

Source code in finance/serializers.py
class Meta:
    """Metadata for BudgetSerializer."""
    model = Budget
    fields = [
        'id', 'name', 'month', 'year', 'total_limit',
        'spent', 'remaining', 'status', 'created_at', 'updated_at',
    ]
    read_only_fields = ['status', 'created_at', 'updated_at', 'spent', 'remaining']

create(validated_data)

Establish a new budget for the authenticated user.

Parameters:

Name Type Description Default
validated_data dict

Validated input data.

required

Returns:

Name Type Description
Budget

Newly created budget instance.

Source code in finance/serializers.py
def create(self, validated_data):
    """
    Establish a new budget for the authenticated user.

    Args:
        validated_data (dict): Validated input data.

    Returns:
        Budget: Newly created budget instance.
    """
    validated_data['user'] = self.context['request'].user
    return super().create(validated_data)

get_remaining(obj)

Calculate available budget balance.

Parameters:

Name Type Description Default
obj Budget

The budget instance.

required

Returns:

Name Type Description
Decimal

remaining amount (total_limit - spent).

Source code in finance/serializers.py
def get_remaining(self, obj):
    """
    Calculate available budget balance.

    Args:
        obj (Budget): The budget instance.

    Returns:
        Decimal: remaining amount (total_limit - spent).
    """
    remaining = obj.total_limit - obj.spent
    return max(remaining, 0)

CategorySerializer

Bases: ModelSerializer

Serializer for expense and income categories.

Automatically handles user assignment and category type identification.

Source code in finance/serializers.py
class CategorySerializer(serializers.ModelSerializer):
    """
    Serializer for expense and income categories.

    Automatically handles user assignment and category type identification.
    """

    class Meta:
        """Metadata for CategorySerializer."""
        model = Category
        fields = ['id', 'name', 'type', 'parent', 'is_predefined', 'created_at']
        read_only_fields = ['is_predefined', 'created_at']

    def create(self, validated_data):
        """
        Create a new custom category.

        Args:
            validated_data (dict): Validated input data.

        Returns:
            Category: Created category instance with authenticated user attached.
        """
        validated_data['user'] = self.context['request'].user
        validated_data['is_predefined'] = False
        return super().create(validated_data)

Meta

Metadata for CategorySerializer.

Source code in finance/serializers.py
class Meta:
    """Metadata for CategorySerializer."""
    model = Category
    fields = ['id', 'name', 'type', 'parent', 'is_predefined', 'created_at']
    read_only_fields = ['is_predefined', 'created_at']

create(validated_data)

Create a new custom category.

Parameters:

Name Type Description Default
validated_data dict

Validated input data.

required

Returns:

Name Type Description
Category

Created category instance with authenticated user attached.

Source code in finance/serializers.py
def create(self, validated_data):
    """
    Create a new custom category.

    Args:
        validated_data (dict): Validated input data.

    Returns:
        Category: Created category instance with authenticated user attached.
    """
    validated_data['user'] = self.context['request'].user
    validated_data['is_predefined'] = False
    return super().create(validated_data)

SavingsGoalSerializer

Bases: ModelSerializer

Serializer for user-defined savings targets.

Computes the 'progress' percentage based on target vs current amounts.

Source code in finance/serializers.py
class SavingsGoalSerializer(serializers.ModelSerializer):
    """
    Serializer for user-defined savings targets.

    Computes the 'progress' percentage based on target vs current amounts.
    """

    progress = serializers.IntegerField(read_only=True)

    class Meta:
        """Metadata for SavingsGoalSerializer."""
        model = SavingsGoal
        fields = [
            'id', 'name', 'target_amount', 'current_amount',
            'deadline', 'completed', 'progress', 'created_at',
        ]
        read_only_fields = ['completed', 'progress', 'created_at', 'current_amount']

    def create(self, validated_data):
        """
        Register a new savings goal for the current user.

        Args:
            validated_data (dict): Validated input data.

        Returns:
            SavingsGoal: Created instance.
        """
        validated_data['user'] = self.context['request'].user
        return super().create(validated_data)

Meta

Metadata for SavingsGoalSerializer.

Source code in finance/serializers.py
class Meta:
    """Metadata for SavingsGoalSerializer."""
    model = SavingsGoal
    fields = [
        'id', 'name', 'target_amount', 'current_amount',
        'deadline', 'completed', 'progress', 'created_at',
    ]
    read_only_fields = ['completed', 'progress', 'created_at', 'current_amount']

create(validated_data)

Register a new savings goal for the current user.

Parameters:

Name Type Description Default
validated_data dict

Validated input data.

required

Returns:

Name Type Description
SavingsGoal

Created instance.

Source code in finance/serializers.py
def create(self, validated_data):
    """
    Register a new savings goal for the current user.

    Args:
        validated_data (dict): Validated input data.

    Returns:
        SavingsGoal: Created instance.
    """
    validated_data['user'] = self.context['request'].user
    return super().create(validated_data)

TransactionSerializer

Bases: ModelSerializer

Serializer for logging financial transactions.

Includes validation for amounts, category matching, and income requirements.

Source code in finance/serializers.py
class TransactionSerializer(serializers.ModelSerializer):
    """
    Serializer for logging financial transactions.

    Includes validation for amounts, category matching, and income requirements.
    """

    category_name = serializers.CharField(source='category.name', read_only=True)

    class Meta:
        """Metadata for TransactionSerializer."""
        model = Transaction
        fields = [
            'id', 'type', 'category', 'category_name',
            'amount', 'date', 'description', 'notes', 'source', 'created_at',
        ]
        read_only_fields = ['created_at', 'category_name']

    def validate(self, data):
        """
        Perform complex cross-field validation.

        Checks:
        1. Amount is positive.
        2. Transaction type matches the chosen category's type.
        3. Income transactions have a source defined.

        Args:
            data (dict): Input data to validate.

        Returns:
            dict: Validated data.

        Raises:
            serializers.ValidationError: If any business rule is violated.
        """
        amount = data.get('amount')
        transaction_type = data.get('type')
        category = data.get('category')
        source = data.get('source', '')

        if amount is None or amount <= 0:
            raise serializers.ValidationError('Amount must be greater than zero.')
        if category is not None and category.type != transaction_type:
            raise serializers.ValidationError('Category type must match transaction type.')
        if transaction_type == Transaction.TYPE_INCOME and not source:
            raise serializers.ValidationError('Income transactions require a source.')
        return data

    def create(self, validated_data):
        """
        Create a transaction via the TransactionService.

        Delegates to the service layer to handle side effects like
        budget limit updates and notification triggers.

        Args:
            validated_data (dict): Validated input data.

        Returns:
            Transaction: The created transaction instance.
        """
        transaction = TransactionService().create_transaction(self.context['request'].user, validated_data)
        return transaction

Meta

Metadata for TransactionSerializer.

Source code in finance/serializers.py
class Meta:
    """Metadata for TransactionSerializer."""
    model = Transaction
    fields = [
        'id', 'type', 'category', 'category_name',
        'amount', 'date', 'description', 'notes', 'source', 'created_at',
    ]
    read_only_fields = ['created_at', 'category_name']

create(validated_data)

Create a transaction via the TransactionService.

Delegates to the service layer to handle side effects like budget limit updates and notification triggers.

Parameters:

Name Type Description Default
validated_data dict

Validated input data.

required

Returns:

Name Type Description
Transaction

The created transaction instance.

Source code in finance/serializers.py
def create(self, validated_data):
    """
    Create a transaction via the TransactionService.

    Delegates to the service layer to handle side effects like
    budget limit updates and notification triggers.

    Args:
        validated_data (dict): Validated input data.

    Returns:
        Transaction: The created transaction instance.
    """
    transaction = TransactionService().create_transaction(self.context['request'].user, validated_data)
    return transaction

validate(data)

Perform complex cross-field validation.

Checks: 1. Amount is positive. 2. Transaction type matches the chosen category's type. 3. Income transactions have a source defined.

Parameters:

Name Type Description Default
data dict

Input data to validate.

required

Returns:

Name Type Description
dict

Validated data.

Raises:

Type Description
ValidationError

If any business rule is violated.

Source code in finance/serializers.py
def validate(self, data):
    """
    Perform complex cross-field validation.

    Checks:
    1. Amount is positive.
    2. Transaction type matches the chosen category's type.
    3. Income transactions have a source defined.

    Args:
        data (dict): Input data to validate.

    Returns:
        dict: Validated data.

    Raises:
        serializers.ValidationError: If any business rule is violated.
    """
    amount = data.get('amount')
    transaction_type = data.get('type')
    category = data.get('category')
    source = data.get('source', '')

    if amount is None or amount <= 0:
        raise serializers.ValidationError('Amount must be greater than zero.')
    if category is not None and category.type != transaction_type:
        raise serializers.ValidationError('Category type must match transaction type.')
    if transaction_type == Transaction.TYPE_INCOME and not source:
        raise serializers.ValidationError('Income transactions require a source.')
    return data

finance.views

Views for managing financial entities.

Provides comprehensive API endpoints for: - Categories (predefined and custom) - Transactions (with filtering and summaries) - Budgets and per-category spending limits - Savings goals with contribution tracking - Financial reports (monthly and by category)

BudgetCategoryLimitViewSet

Bases: OwnerMixin, ModelViewSet

ViewSet for managing granular spending limits within a budget.

Allows users to assign specific portions of their total budget to individual categories.

Source code in finance/views.py
class BudgetCategoryLimitViewSet(OwnerMixin, viewsets.ModelViewSet):
    """
    ViewSet for managing granular spending limits within a budget.

    Allows users to assign specific portions of their total budget to individual categories.
    """

    queryset = BudgetCategoryLimit.objects.all()
    serializer_class = BudgetCategoryLimitSerializer

    def get_queryset(self):
        """
        Filter limits based on the owner of the parent budget.

        Returns:
            QuerySet: Category limits belonging to the user.
        """
        return BudgetCategoryLimit.objects.filter(budget__user=self.request.user)

get_queryset()

Filter limits based on the owner of the parent budget.

Returns:

Name Type Description
QuerySet

Category limits belonging to the user.

Source code in finance/views.py
def get_queryset(self):
    """
    Filter limits based on the owner of the parent budget.

    Returns:
        QuerySet: Category limits belonging to the user.
    """
    return BudgetCategoryLimit.objects.filter(budget__user=self.request.user)

BudgetViewSet

Bases: OwnerMixin, ModelViewSet

ViewSet for managing overall monthly budgets.

Allows setting the total spending limit for a specific month and year.

Source code in finance/views.py
class BudgetViewSet(OwnerMixin, viewsets.ModelViewSet):
    """
    ViewSet for managing overall monthly budgets.

    Allows setting the total spending limit for a specific month and year.
    """

    queryset = Budget.objects.all()
    serializer_class = BudgetSerializer

CategoryViewSet

Bases: OwnerMixin, ModelViewSet

ViewSet for managing transaction categories.

Allows listing all categories (including global predefined ones) and creating/editing custom user-specific categories.

Source code in finance/views.py
class CategoryViewSet(OwnerMixin, viewsets.ModelViewSet):
    """
    ViewSet for managing transaction categories.

    Allows listing all categories (including global predefined ones)
    and creating/editing custom user-specific categories.
    """

    serializer_class = CategorySerializer

    def get_queryset(self):
        """
        Return user-specific categories combined with system-wide predefined categories.

        Returns:
            QuerySet: Categories matching the criteria.
        """
        return Category.objects.filter(
            Q(user=self.request.user) | Q(is_predefined=True)
        )

    def perform_create(self, serializer):
        """
        Assign the authenticated user to newly created categories.

        Args:
            serializer (CategorySerializer): The category serializer.
        """
        serializer.save(user=self.request.user)

get_queryset()

Return user-specific categories combined with system-wide predefined categories.

Returns:

Name Type Description
QuerySet

Categories matching the criteria.

Source code in finance/views.py
def get_queryset(self):
    """
    Return user-specific categories combined with system-wide predefined categories.

    Returns:
        QuerySet: Categories matching the criteria.
    """
    return Category.objects.filter(
        Q(user=self.request.user) | Q(is_predefined=True)
    )

perform_create(serializer)

Assign the authenticated user to newly created categories.

Parameters:

Name Type Description Default
serializer CategorySerializer

The category serializer.

required
Source code in finance/views.py
def perform_create(self, serializer):
    """
    Assign the authenticated user to newly created categories.

    Args:
        serializer (CategorySerializer): The category serializer.
    """
    serializer.save(user=self.request.user)

OwnerMixin

Mixin to restrict queryset access to the authenticated user's own data.

Ensures that users can only view, update, or delete objects they created.

Source code in finance/views.py
class OwnerMixin:
    """
    Mixin to restrict queryset access to the authenticated user's own data.

    Ensures that users can only view, update, or delete objects they created.
    """

    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        """
        Filter the base queryset to only include items belonging to the current user.

        Returns:
            QuerySet: Filtered queryset.
        """
        return super().get_queryset().filter(user=self.request.user)

get_queryset()

Filter the base queryset to only include items belonging to the current user.

Returns:

Name Type Description
QuerySet

Filtered queryset.

Source code in finance/views.py
def get_queryset(self):
    """
    Filter the base queryset to only include items belonging to the current user.

    Returns:
        QuerySet: Filtered queryset.
    """
    return super().get_queryset().filter(user=self.request.user)

ReportViewSet

Bases: ViewSet

ViewSet for high-level financial reports.

Provides aggregated analytics about income, expenses, and category distributions.

Source code in finance/views.py
class ReportViewSet(viewsets.ViewSet):
    """
    ViewSet for high-level financial reports.

    Provides aggregated analytics about income, expenses, and category distributions.
    """

    permission_classes = [permissions.IsAuthenticated]

    def list(self, request):
        """Return basic instructions for using the report endpoints."""
        return Response({'detail': 'Use the monthly or category endpoints to fetch report data.'})

    @extend_schema(
        summary="Monthly Summary Report",
        responses={200: inline_serializer('ReportMonthlyResponse', {'income': serializers.DecimalField(max_digits=14, decimal_places=2), 'expense': serializers.DecimalField(max_digits=14, decimal_places=2), 'balance': serializers.DecimalField(max_digits=14, decimal_places=2)})}
    )
    @action(detail=False, methods=['get'])
    def monthly(self, request):
        """
        Generate a monthly financial summary.

        Returns:
            Response: Aggregated monthly data (Income, Expense, Balance).
        """
        month = request.query_params.get('month')
        year = request.query_params.get('year')
        data = ReportService().monthly_summary(request.user, month, year)
        return Response(data)

    @extend_schema(
        summary="Category Distribution Report",
        responses={200: inline_serializer('ReportCategorySummaryResponse', {'category_id': serializers.IntegerField(), 'category_name': serializers.CharField(), 'total': serializers.DecimalField(max_digits=14, decimal_places=2)}, many=True)}
    )
    @action(detail=False, methods=['get'])
    def by_category(self, request):
        """
        Generate a breakdown of spending by category for a period.

        Returns:
            Response: List of categories with total amounts spent in each.
        """
        month = request.query_params.get('month')
        year = request.query_params.get('year')
        data = ReportService().category_summary(request.user, month, year)
        return Response(data)

by_category(request)

Generate a breakdown of spending by category for a period.

Returns:

Name Type Description
Response

List of categories with total amounts spent in each.

Source code in finance/views.py
@extend_schema(
    summary="Category Distribution Report",
    responses={200: inline_serializer('ReportCategorySummaryResponse', {'category_id': serializers.IntegerField(), 'category_name': serializers.CharField(), 'total': serializers.DecimalField(max_digits=14, decimal_places=2)}, many=True)}
)
@action(detail=False, methods=['get'])
def by_category(self, request):
    """
    Generate a breakdown of spending by category for a period.

    Returns:
        Response: List of categories with total amounts spent in each.
    """
    month = request.query_params.get('month')
    year = request.query_params.get('year')
    data = ReportService().category_summary(request.user, month, year)
    return Response(data)

list(request)

Return basic instructions for using the report endpoints.

Source code in finance/views.py
def list(self, request):
    """Return basic instructions for using the report endpoints."""
    return Response({'detail': 'Use the monthly or category endpoints to fetch report data.'})

monthly(request)

Generate a monthly financial summary.

Returns:

Name Type Description
Response

Aggregated monthly data (Income, Expense, Balance).

Source code in finance/views.py
@extend_schema(
    summary="Monthly Summary Report",
    responses={200: inline_serializer('ReportMonthlyResponse', {'income': serializers.DecimalField(max_digits=14, decimal_places=2), 'expense': serializers.DecimalField(max_digits=14, decimal_places=2), 'balance': serializers.DecimalField(max_digits=14, decimal_places=2)})}
)
@action(detail=False, methods=['get'])
def monthly(self, request):
    """
    Generate a monthly financial summary.

    Returns:
        Response: Aggregated monthly data (Income, Expense, Balance).
    """
    month = request.query_params.get('month')
    year = request.query_params.get('year')
    data = ReportService().monthly_summary(request.user, month, year)
    return Response(data)

SavingsGoalViewSet

Bases: OwnerMixin, ModelViewSet

ViewSet for managing long-term savings goals.

Provides a custom 'contribute' action to add money to a goal.

Source code in finance/views.py
class SavingsGoalViewSet(OwnerMixin, viewsets.ModelViewSet):
    """
    ViewSet for managing long-term savings goals.

    Provides a custom 'contribute' action to add money to a goal.
    """

    queryset = SavingsGoal.objects.all()
    serializer_class = SavingsGoalSerializer

    @extend_schema(
        summary="Contribute to Goal",
        description="Add a specific amount to the current balance of a savings goal.",
        request=inline_serializer('ContributeRequest', {'amount': serializers.DecimalField(max_digits=14, decimal_places=2)}), 
        responses=SavingsGoalSerializer
    )
    @action(detail=True, methods=['post'])
    def contribute(self, request, pk=None):
        """
        Add funds to a specific savings goal.

        Expected Data:
            amount (Decimal): The contribution value.

        Returns:
            Response: The updated goal instance.
        """
        goal = self.get_object()
        amount_raw = request.data.get('amount')

        if amount_raw is None:
            return Response(
                {'error': 'amount is required.'},
                status=status.HTTP_400_BAD_REQUEST,
            )

        try:
            amount = Decimal(str(amount_raw))
        except Exception:
            return Response(
                {'error': 'amount must be a valid number.'},
                status=status.HTTP_400_BAD_REQUEST,
            )

        if amount <= 0:
            return Response(
                {'error': 'amount must be greater than zero.'},
                status=status.HTTP_400_BAD_REQUEST,
            )

        goal.add_contribution(amount)
        return Response(SavingsGoalSerializer(goal).data, status=status.HTTP_200_OK)

contribute(request, pk=None)

Add funds to a specific savings goal.

Expected Data

amount (Decimal): The contribution value.

Returns:

Name Type Description
Response

The updated goal instance.

Source code in finance/views.py
@extend_schema(
    summary="Contribute to Goal",
    description="Add a specific amount to the current balance of a savings goal.",
    request=inline_serializer('ContributeRequest', {'amount': serializers.DecimalField(max_digits=14, decimal_places=2)}), 
    responses=SavingsGoalSerializer
)
@action(detail=True, methods=['post'])
def contribute(self, request, pk=None):
    """
    Add funds to a specific savings goal.

    Expected Data:
        amount (Decimal): The contribution value.

    Returns:
        Response: The updated goal instance.
    """
    goal = self.get_object()
    amount_raw = request.data.get('amount')

    if amount_raw is None:
        return Response(
            {'error': 'amount is required.'},
            status=status.HTTP_400_BAD_REQUEST,
        )

    try:
        amount = Decimal(str(amount_raw))
    except Exception:
        return Response(
            {'error': 'amount must be a valid number.'},
            status=status.HTTP_400_BAD_REQUEST,
        )

    if amount <= 0:
        return Response(
            {'error': 'amount must be greater than zero.'},
            status=status.HTTP_400_BAD_REQUEST,
        )

    goal.add_contribution(amount)
    return Response(SavingsGoalSerializer(goal).data, status=status.HTTP_200_OK)

TransactionViewSet

Bases: OwnerMixin, ModelViewSet

ViewSet for logging and viewing financial transactions.

Supports filtering by type, category, and date range.

Source code in finance/views.py
class TransactionViewSet(OwnerMixin, viewsets.ModelViewSet):
    """
    ViewSet for logging and viewing financial transactions.

    Supports filtering by type, category, and date range.
    """

    queryset = Transaction.objects.all()
    serializer_class = TransactionSerializer

    def get_queryset(self):
        """
        Apply optional filters based on query parameters.

        Filters:
        - type: 'expense' or 'income'
        - category: ID of the category
        - date_from: Start date (YYYY-MM-DD)
        - date_to: End date (YYYY-MM-DD)

        Returns:
            QuerySet: Filtered transaction list.
        """
        queryset = super().get_queryset()
        params = self.request.query_params

        transaction_type = params.get('type')
        category_id = params.get('category')
        date_from = params.get('date_from')
        date_to = params.get('date_to')

        if transaction_type:
            queryset = queryset.filter(type=transaction_type)
        if category_id:
            queryset = queryset.filter(category_id=category_id)
        if date_from:
            queryset = queryset.filter(date__gte=date_from)
        if date_to:
            queryset = queryset.filter(date__lte=date_to)

        return queryset

    @extend_schema(
        summary="Transaction Summary",
        description="Calculate total income, expense, and balance for an optional period.",
        responses={200: inline_serializer('TransactionSummaryResponse', {'income': serializers.DecimalField(max_digits=14, decimal_places=2), 'expense': serializers.DecimalField(max_digits=14, decimal_places=2), 'balance': serializers.DecimalField(max_digits=14, decimal_places=2)})}
    )
    @action(detail=False, methods=['get'])
    def summary(self, request):
        """
        Calculate the aggregate financial balance.

        Query Parameters:
            month (int): Filter by month.
            year (int): Filter by year.

        Returns:
            Response: Object containing totals for income, expense, and net balance.
        """
        queryset = self.get_queryset()
        month = request.query_params.get('month')
        year = request.query_params.get('year')
        if month and year:
            queryset = queryset.filter(date__year=year, date__month=month)

        total_income = queryset.filter(
            type=Transaction.TYPE_INCOME
        ).aggregate(total=Sum('amount'))['total'] or 0
        total_expense = queryset.filter(
            type=Transaction.TYPE_EXPENSE
        ).aggregate(total=Sum('amount'))['total'] or 0

        return Response({
            'income': total_income,
            'expense': total_expense,
            'balance': total_income - total_expense,
        })

    @extend_schema(
        summary="Transactions by Category",
        description="Return total spending aggregated by category for a period.",
        responses={200: inline_serializer('TransactionCategorySummaryResponse', {'category_id': serializers.IntegerField(), 'category_name': serializers.CharField(), 'total': serializers.DecimalField(max_digits=14, decimal_places=2)}, many=True)}
    )
    @action(detail=False, methods=['get'])
    def by_category(self, request):
        """
        Aggregate spending by category.

        Returns a list of categories and the total amount spent in each.
        """
        queryset = self.get_queryset().filter(category__isnull=False)
        month = request.query_params.get('month')
        year = request.query_params.get('year')
        if month and year:
            queryset = queryset.filter(date__year=year, date__month=month)

        data = (
            queryset
            .values('category__id', 'category__name')
            .annotate(total=Sum('amount'))
            .order_by('-total')
        )
        return Response([
            {
                'category_id': item['category__id'],
                'category_name': item['category__name'],
                'total': item['total'],
            }
            for item in data
        ])

by_category(request)

Aggregate spending by category.

Returns a list of categories and the total amount spent in each.

Source code in finance/views.py
@extend_schema(
    summary="Transactions by Category",
    description="Return total spending aggregated by category for a period.",
    responses={200: inline_serializer('TransactionCategorySummaryResponse', {'category_id': serializers.IntegerField(), 'category_name': serializers.CharField(), 'total': serializers.DecimalField(max_digits=14, decimal_places=2)}, many=True)}
)
@action(detail=False, methods=['get'])
def by_category(self, request):
    """
    Aggregate spending by category.

    Returns a list of categories and the total amount spent in each.
    """
    queryset = self.get_queryset().filter(category__isnull=False)
    month = request.query_params.get('month')
    year = request.query_params.get('year')
    if month and year:
        queryset = queryset.filter(date__year=year, date__month=month)

    data = (
        queryset
        .values('category__id', 'category__name')
        .annotate(total=Sum('amount'))
        .order_by('-total')
    )
    return Response([
        {
            'category_id': item['category__id'],
            'category_name': item['category__name'],
            'total': item['total'],
        }
        for item in data
    ])

get_queryset()

Apply optional filters based on query parameters.

Filters: - type: 'expense' or 'income' - category: ID of the category - date_from: Start date (YYYY-MM-DD) - date_to: End date (YYYY-MM-DD)

Returns:

Name Type Description
QuerySet

Filtered transaction list.

Source code in finance/views.py
def get_queryset(self):
    """
    Apply optional filters based on query parameters.

    Filters:
    - type: 'expense' or 'income'
    - category: ID of the category
    - date_from: Start date (YYYY-MM-DD)
    - date_to: End date (YYYY-MM-DD)

    Returns:
        QuerySet: Filtered transaction list.
    """
    queryset = super().get_queryset()
    params = self.request.query_params

    transaction_type = params.get('type')
    category_id = params.get('category')
    date_from = params.get('date_from')
    date_to = params.get('date_to')

    if transaction_type:
        queryset = queryset.filter(type=transaction_type)
    if category_id:
        queryset = queryset.filter(category_id=category_id)
    if date_from:
        queryset = queryset.filter(date__gte=date_from)
    if date_to:
        queryset = queryset.filter(date__lte=date_to)

    return queryset

summary(request)

Calculate the aggregate financial balance.

Query Parameters

month (int): Filter by month. year (int): Filter by year.

Returns:

Name Type Description
Response

Object containing totals for income, expense, and net balance.

Source code in finance/views.py
@extend_schema(
    summary="Transaction Summary",
    description="Calculate total income, expense, and balance for an optional period.",
    responses={200: inline_serializer('TransactionSummaryResponse', {'income': serializers.DecimalField(max_digits=14, decimal_places=2), 'expense': serializers.DecimalField(max_digits=14, decimal_places=2), 'balance': serializers.DecimalField(max_digits=14, decimal_places=2)})}
)
@action(detail=False, methods=['get'])
def summary(self, request):
    """
    Calculate the aggregate financial balance.

    Query Parameters:
        month (int): Filter by month.
        year (int): Filter by year.

    Returns:
        Response: Object containing totals for income, expense, and net balance.
    """
    queryset = self.get_queryset()
    month = request.query_params.get('month')
    year = request.query_params.get('year')
    if month and year:
        queryset = queryset.filter(date__year=year, date__month=month)

    total_income = queryset.filter(
        type=Transaction.TYPE_INCOME
    ).aggregate(total=Sum('amount'))['total'] or 0
    total_expense = queryset.filter(
        type=Transaction.TYPE_EXPENSE
    ).aggregate(total=Sum('amount'))['total'] or 0

    return Response({
        'income': total_income,
        'expense': total_expense,
        'balance': total_income - total_expense,
    })

finance.services

Services for the finance application.

Contains the business logic for processing transactions, managing budgets, and generating reports. This layer abstracts complex logic away from views and models to maintain clean architecture.

BudgetService

Service for linking expense transactions to budgets and updating spending totals.

Handles the side effects of transaction creation on budget limits.

Source code in finance/services.py
class BudgetService:
    """
    Service for linking expense transactions to budgets and updating spending totals.

    Handles the side effects of transaction creation on budget limits.
    """

    def get_budget_for_transaction(self, transaction):
        """
        Return the budget for the same month/year as the transaction, if one exists.

        Args:
            transaction (Transaction): The transaction instance.

        Returns:
            Budget: The matching budget instance or None.
        """
        return Budget.objects.filter(
            user=transaction.user,
            month=transaction.date.month,
            year=transaction.date.year,
        ).first()

    def process_expense(self, transaction):
        """
        Update the spent amount on the relevant budget category limit.

        Also triggers a recalculation of the overall budget status.

        Args:
            transaction (Transaction): The expense transaction being processed.
        """
        budget = self.get_budget_for_transaction(transaction)
        if not budget:
            return
        if transaction.category is not None:
            limit = BudgetCategoryLimit.objects.filter(
                budget=budget,
                category=transaction.category,
            ).first()
            if limit:
                limit.spent += transaction.amount
                limit.save(update_fields=['spent'])
                limit.update_status()
        budget.update_status()

get_budget_for_transaction(transaction)

Return the budget for the same month/year as the transaction, if one exists.

Parameters:

Name Type Description Default
transaction Transaction

The transaction instance.

required

Returns:

Name Type Description
Budget

The matching budget instance or None.

Source code in finance/services.py
def get_budget_for_transaction(self, transaction):
    """
    Return the budget for the same month/year as the transaction, if one exists.

    Args:
        transaction (Transaction): The transaction instance.

    Returns:
        Budget: The matching budget instance or None.
    """
    return Budget.objects.filter(
        user=transaction.user,
        month=transaction.date.month,
        year=transaction.date.year,
    ).first()

process_expense(transaction)

Update the spent amount on the relevant budget category limit.

Also triggers a recalculation of the overall budget status.

Parameters:

Name Type Description Default
transaction Transaction

The expense transaction being processed.

required
Source code in finance/services.py
def process_expense(self, transaction):
    """
    Update the spent amount on the relevant budget category limit.

    Also triggers a recalculation of the overall budget status.

    Args:
        transaction (Transaction): The expense transaction being processed.
    """
    budget = self.get_budget_for_transaction(transaction)
    if not budget:
        return
    if transaction.category is not None:
        limit = BudgetCategoryLimit.objects.filter(
            budget=budget,
            category=transaction.category,
        ).first()
        if limit:
            limit.spent += transaction.amount
            limit.save(update_fields=['spent'])
            limit.update_status()
    budget.update_status()

ReportService

Service for generating financial reports aggregated by period or category.

Source code in finance/services.py
class ReportService:
    """
    Service for generating financial reports aggregated by period or category.
    """

    def _period_queryset(self, user, month, year):
        """
        Return a queryset of the user's transactions, optionally filtered to a month/year.

        Args:
            user (User): The user whose data to fetch.
            month (int, optional): The month filter.
            year (int, optional): The year filter.

        Returns:
            QuerySet: Transaction queryset.
        """
        queryset = Transaction.objects.filter(user=user)
        if month and year:
            queryset = queryset.filter(date__month=month, date__year=year)
        return queryset

    def monthly_summary(self, user, month=None, year=None):
        """
        Return total income, total expense, and net balance for the given period.

        Args:
            user (User): The user whose summary to generate.
            month (int, optional): Month filter.
            year (int, optional): Year filter.

        Returns:
            dict: Summary data containing 'income', 'expense', and 'balance'.
        """
        queryset = self._period_queryset(user, month, year)
        total_income = queryset.filter(
            type=Transaction.TYPE_INCOME
        ).aggregate(total=Sum('amount'))['total'] or 0
        total_expense = queryset.filter(
            type=Transaction.TYPE_EXPENSE
        ).aggregate(total=Sum('amount'))['total'] or 0
        return {
            'income': total_income,
            'expense': total_expense,
            'balance': total_income - total_expense,
        }

    def category_summary(self, user, month=None, year=None):
        """
        Return spending totals grouped by category for the given period.

        Args:
            user (User): The user whose summary to generate.
            month (int, optional): Month filter.
            year (int, optional): Year filter.

        Returns:
            list: List of dictionaries with 'category_id', 'category_name', and 'total'.
        """
        queryset = self._period_queryset(user, month, year).filter(category__isnull=False)
        data = (
            queryset
            .values('category__id', 'category__name')
            .annotate(total=Sum('amount'))
            .order_by('-total')
        )
        return [
            {
                'category_id': item['category__id'],
                'category_name': item['category__name'],
                'total': item['total'],
            }
            for item in data
        ]

category_summary(user, month=None, year=None)

Return spending totals grouped by category for the given period.

Parameters:

Name Type Description Default
user User

The user whose summary to generate.

required
month int

Month filter.

None
year int

Year filter.

None

Returns:

Name Type Description
list

List of dictionaries with 'category_id', 'category_name', and 'total'.

Source code in finance/services.py
def category_summary(self, user, month=None, year=None):
    """
    Return spending totals grouped by category for the given period.

    Args:
        user (User): The user whose summary to generate.
        month (int, optional): Month filter.
        year (int, optional): Year filter.

    Returns:
        list: List of dictionaries with 'category_id', 'category_name', and 'total'.
    """
    queryset = self._period_queryset(user, month, year).filter(category__isnull=False)
    data = (
        queryset
        .values('category__id', 'category__name')
        .annotate(total=Sum('amount'))
        .order_by('-total')
    )
    return [
        {
            'category_id': item['category__id'],
            'category_name': item['category__name'],
            'total': item['total'],
        }
        for item in data
    ]

monthly_summary(user, month=None, year=None)

Return total income, total expense, and net balance for the given period.

Parameters:

Name Type Description Default
user User

The user whose summary to generate.

required
month int

Month filter.

None
year int

Year filter.

None

Returns:

Name Type Description
dict

Summary data containing 'income', 'expense', and 'balance'.

Source code in finance/services.py
def monthly_summary(self, user, month=None, year=None):
    """
    Return total income, total expense, and net balance for the given period.

    Args:
        user (User): The user whose summary to generate.
        month (int, optional): Month filter.
        year (int, optional): Year filter.

    Returns:
        dict: Summary data containing 'income', 'expense', and 'balance'.
    """
    queryset = self._period_queryset(user, month, year)
    total_income = queryset.filter(
        type=Transaction.TYPE_INCOME
    ).aggregate(total=Sum('amount'))['total'] or 0
    total_expense = queryset.filter(
        type=Transaction.TYPE_EXPENSE
    ).aggregate(total=Sum('amount'))['total'] or 0
    return {
        'income': total_income,
        'expense': total_expense,
        'balance': total_income - total_expense,
    }

TransactionService

Service for creating transactions and triggering post-creation side effects.

Acts as the entry point for transaction creation logic.

Source code in finance/services.py
class TransactionService:
    """
    Service for creating transactions and triggering post-creation side effects.

    Acts as the entry point for transaction creation logic.
    """

    def create_transaction(self, user, validated_data):
        """
        Create a transaction and process it against the budget if it's an expense.

        Args:
            user (User): The user creating the transaction.
            validated_data (dict): Validated input data from the serializer.

        Returns:
            Transaction: The newly created transaction.
        """
        validated_data['user'] = user
        transaction = Transaction.objects.create(**validated_data)
        if transaction.type == Transaction.TYPE_EXPENSE:
            BudgetService().process_expense(transaction)
        return transaction

create_transaction(user, validated_data)

Create a transaction and process it against the budget if it's an expense.

Parameters:

Name Type Description Default
user User

The user creating the transaction.

required
validated_data dict

Validated input data from the serializer.

required

Returns:

Name Type Description
Transaction

The newly created transaction.

Source code in finance/services.py
def create_transaction(self, user, validated_data):
    """
    Create a transaction and process it against the budget if it's an expense.

    Args:
        user (User): The user creating the transaction.
        validated_data (dict): Validated input data from the serializer.

    Returns:
        Transaction: The newly created transaction.
    """
    validated_data['user'] = user
    transaction = Transaction.objects.create(**validated_data)
    if transaction.type == Transaction.TYPE_EXPENSE:
        BudgetService().process_expense(transaction)
    return transaction