All files / src/services/db budget.db.ts

84.81% Statements 162/191
79.31% Branches 23/29
100% Functions 14/14
84.17% Lines 133/158

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 1592x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 43x 2x 2x 2x 2x 2x 2x 2x 2x 5x 5x 2x 2x 2x 4x 2x 2x 3x 3x 3x 3x 3x 3x 3x 3x           2x 2x 2x 8x 8x 8x 8x 2x 2x 2x 2x 2x 2x 5x 5x 4x 2x 2x 4x                     2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 6x 6x 6x 2x 2x 2x 2x 2x 2x 2x 1x 5x 4x 2x 1x 4x 1x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 5x 5x 2x 2x 1x 4x 2x 1x 1x 3x 1x       2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 1x 1x 1x 1x 1x 4x 4x 1x     3x   1x         2x 2x 2x 2x 2x  
// src/services/db/budget.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool, withTransaction } from './connection.db';
import { NotFoundError, handleDbError } from './errors.db';
import type { Logger } from 'pino';
import type { Budget, SpendingByCategory } from '../../types';
import { GamificationRepository } from './gamification.db';
 
export class BudgetRepository {
  // The repository only needs an object with a `query` method, matching the Pool/PoolClient interface.
  // Using `Pick` makes this dependency explicit and simplifies testing by reducing the mock surface.
  private db: Pick<Pool | PoolClient, 'query'>;
 
  constructor(db: Pick<Pool | PoolClient, 'query'> = getPool()) {
    this.db = db;
  }
 
  /**
   * Retrieves all budgets for a specific user.
   * @param userId The UUID of the user.
   * @returns A promise that resolves to an array of Budget objects.
   */
  async getBudgetsForUser(userId: string, logger: Logger): Promise<Budget[]> {
    try {
      const res = await this.db.query<Budget>(
        'SELECT * FROM public.budgets WHERE user_id = $1 ORDER BY start_date DESC',
        [userId],
      );
      return res.rows;
    } catch (error) {
      handleDbError(error, logger, 'Database error in getBudgetsForUser', { userId }, {
        defaultMessage: 'Failed to retrieve budgets.',
      });
    }
  }
 
  /**
   * Creates a new budget for a user.
   * @param userId The ID of the user creating the budget.
   * @param budgetData The data for the new budget.
   * @returns A promise that resolves to the newly created Budget object.
   */
  async createBudget(
    userId: string,
    budgetData: Omit<Budget, 'budget_id' | 'user_id' | 'created_at' | 'updated_at'>,
    logger: Logger,
  ): Promise<Budget> {
    const { name, amount_cents, period, start_date } = budgetData;
    try {
      return await withTransaction(async (client) => {
        const res = await client.query<Budget>(
          'INSERT INTO public.budgets (user_id, name, amount_cents, period, start_date) VALUES ($1, $2, $3, $4, $5) RETURNING *',
          [userId, name, amount_cents, period, start_date],
        );
 
        // After successfully creating the budget, try to award the 'First Budget Created' achievement.
        // The award_achievement function handles checking if the user already has it.
        const gamificationRepo = new GamificationRepository(client);
        await gamificationRepo.awardAchievement(userId, 'First Budget Created', logger);
        return res.rows[0];
      });
    } catch (error) {
      handleDbError(error, logger, 'Database error in createBudget', { budgetData, userId }, {
        fkMessage: 'The specified user does not exist.',
        notNullMessage: 'One or more required budget fields are missing.',
        checkMessage: 'Invalid value provided for budget period.',
        defaultMessage: 'Failed to create budget.',
      });
    }
  }

  /**
   * Updates an existing budget.
   * @param budgetId The ID of the budget to update.
   * @param userId The ID of the user who owns the budget (for verification).
   * @param budgetData The data to update.
   * @returns A promise that resolves to the updated Budget object.
   */
  async updateBudget(
    budgetId: number,
    userId: string,
    budgetData: Partial<Omit<Budget, 'budget_id' | 'user_id' | 'created_at' | 'updated_at'>>,
    logger: Logger,
  ): Promise<Budget> {
    const { name, amount_cents, period, start_date } = budgetData;
    try {
      const res = await this.db.query<Budget>(
        `UPDATE public.budgets SET 
           name = COALESCE($1, name), 
           amount_cents = COALESCE($2, amount_cents), 
           period = COALESCE($3, period), 
           start_date = COALESCE($4, start_date)
         WHERE budget_id = $5 AND user_id = $6 RETURNING *`,
        [name, amount_cents, period, start_date, budgetId, userId],
      );
      if (res.rowCount === 0)
        throw new NotFoundError('Budget not found or user does not have permission to update.');
      return res.rows[0];
    } catch (error) {
      if (error instanceof NotFoundError) throw error;
      handleDbError(error, logger, 'Database error in updateBudget', { budgetId, userId }, {
        defaultMessage: 'Failed to update budget.',
      });
    }
  }
 
  /**
   * Deletes a budget.
   * @param budgetId The ID of the budget to delete.
   * @param userId The ID of the user who owns the budget (for verification).
   */
  async deleteBudget(budgetId: number, userId: string, logger: Logger): Promise<void> {
    try {
      const result = await this.db.query(
        'DELETE FROM public.budgets WHERE budget_id = $1 AND user_id = $2',
        [budgetId, userId],
      );
      if (result.rowCount === 0) {
        throw new NotFoundError('Budget not found or user does not have permission to delete.');
      }
    } catch (error) {
      if (error instanceof NotFoundError) throw error;
      handleDbError(error, logger, 'Database error in deleteBudget', { budgetId, userId }, {
        defaultMessage: 'Failed to delete budget.',
      });
    }
  }
 
  /**
   * Calls the database function to get a user's spending breakdown by category.
   * @param userId The ID of the user.
   * @param startDate The start of the date range.
   * @param endDate The end of the date range.
   * @returns A promise that resolves to an array of spending data.
   */
  async getSpendingByCategory(
    userId: string,
    startDate: string,
    endDate: string,
    logger: Logger,
  ): Promise<SpendingByCategory[]> {
    try {
      const res = await this.db.query<SpendingByCategory>(
        'SELECT * FROM public.get_spending_by_category($1, $2, $3)',
        [userId, startDate, endDate],
      );
      return res.rows;
    } catch (error) {
      handleDbError(
        error,
        logger,
        'Database error in getSpendingByCategory',
        { userId, startDate, endDate },
        { defaultMessage: 'Failed to get spending analysis.' },
      );
    }
  }
}