From 2d38cfb0119d26e640c4b091c4a17c1730208280 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Wed, 6 May 2026 12:50:46 -0600 Subject: [PATCH] feat(api): expose budget state (#1640) * feat(api): expose budget state * fix(api): guard malformed budget ids * fix(api): address budget state review * fix(api): address budget state review * fix(api): document budget id formats * fix(api): align budget category docs auth * fix(api): lighten budget category index payload * fix(api): use shared pagination clamp * fix(api): centralize budget filter handling --- app/controllers/api/v1/accounts_controller.rb | 20 - app/controllers/api/v1/base_controller.rb | 5 + .../api/v1/budget_categories_controller.rb | 63 +++ app/controllers/api/v1/budgets_controller.rb | 47 ++ .../_budget_category.json.jbuilder | 34 ++ .../v1/budget_categories/index.json.jbuilder | 12 + .../v1/budget_categories/show.json.jbuilder | 3 + .../api/v1/budgets/_budget.json.jbuilder | 36 ++ app/views/api/v1/budgets/index.json.jbuilder | 12 + app/views/api/v1/budgets/show.json.jbuilder | 3 + config/routes.rb | 2 + docs/api/openapi.yaml | 484 ++++++++++++++++++ .../requests/api/v1/budget_categories_spec.rb | 162 ++++++ spec/requests/api/v1/budgets_spec.rb | 148 ++++++ spec/swagger_helper.rb | 132 +++++ .../v1/budget_categories_controller_test.rb | 154 ++++++ .../api/v1/budgets_controller_test.rb | 141 +++++ test/test_helper.rb | 4 + 18 files changed, 1442 insertions(+), 20 deletions(-) create mode 100644 app/controllers/api/v1/budget_categories_controller.rb create mode 100644 app/controllers/api/v1/budgets_controller.rb create mode 100644 app/views/api/v1/budget_categories/_budget_category.json.jbuilder create mode 100644 app/views/api/v1/budget_categories/index.json.jbuilder create mode 100644 app/views/api/v1/budget_categories/show.json.jbuilder create mode 100644 app/views/api/v1/budgets/_budget.json.jbuilder create mode 100644 app/views/api/v1/budgets/index.json.jbuilder create mode 100644 app/views/api/v1/budgets/show.json.jbuilder create mode 100644 spec/requests/api/v1/budget_categories_spec.rb create mode 100644 spec/requests/api/v1/budgets_spec.rb create mode 100644 test/controllers/api/v1/budget_categories_controller_test.rb create mode 100644 test/controllers/api/v1/budgets_controller_test.rb diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 5e849a2c4..8b03a5f1f 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -69,24 +69,4 @@ class Api::V1::AccountsController < Api::V1::BaseController def include_disabled_accounts? ActiveModel::Type::Boolean.new.cast(params[:include_disabled]) end - - def valid_uuid?(value) - value.to_s.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i) - end - - def safe_page_param - page = params[:page].to_i - page > 0 ? page : 1 - end - - def safe_per_page_param - per_page = params[:per_page].to_i - - case per_page - when 1..100 - per_page - else - 25 - end - end end diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index 800c02a05..3c2b21a71 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -35,6 +35,7 @@ class Api::V1::BaseController < ApplicationController rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found rescue_from Doorkeeper::Errors::DoorkeeperError, with: :handle_unauthorized rescue_from ActionController::ParameterMissing, with: :handle_bad_request + rescue_from InvalidFilterError, with: :handle_invalid_filter private @@ -256,6 +257,10 @@ class Api::V1::BaseController < ApplicationController render_json({ error: "bad_request", message: "Required parameters are missing or invalid" }, status: :bad_request) end + def handle_invalid_filter(exception) + render_validation_error(exception.message) + end + def parse_date_param(key) Date.iso8601(params[key].to_s) rescue ArgumentError diff --git a/app/controllers/api/v1/budget_categories_controller.rb b/app/controllers/api/v1/budget_categories_controller.rb new file mode 100644 index 000000000..5628e749e --- /dev/null +++ b/app/controllers/api/v1/budget_categories_controller.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class Api::V1::BudgetCategoriesController < Api::V1::BaseController + include Pagy::Backend + + before_action :ensure_read_scope + before_action :set_budget_category, only: :show + + def index + budget_categories_query = apply_filters(budget_categories_scope) + .order("budgets.start_date DESC", "categories.name ASC") + @per_page = safe_per_page_param + + @pagy, @budget_categories = pagy( + budget_categories_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + end + + def show + render :show + end + + private + + def set_budget_category + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + + @budget_category = budget_categories_scope.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def budget_categories_scope + BudgetCategory + .joins(:budget, :category) + .where(budgets: { family_id: current_resource_owner.family_id }) + .includes({ budget: { budget_categories: { category: :parent } } }, category: :parent) + end + + def apply_filters(query) + if params[:budget_id].present? + raise InvalidFilterError, "budget_id must be a valid UUID" unless valid_uuid?(params[:budget_id]) + + query = query.where(budget_id: params[:budget_id]) + end + + if params[:category_id].present? + raise InvalidFilterError, "category_id must be a valid UUID" unless valid_uuid?(params[:category_id]) + + query = query.where(category_id: params[:category_id]) + end + + query = query.where("budgets.start_date >= ?", parse_date_param(:start_date)) if params[:start_date].present? + query = query.where("budgets.end_date <= ?", parse_date_param(:end_date)) if params[:end_date].present? + query + end +end diff --git a/app/controllers/api/v1/budgets_controller.rb b/app/controllers/api/v1/budgets_controller.rb new file mode 100644 index 000000000..d9203faaa --- /dev/null +++ b/app/controllers/api/v1/budgets_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Api::V1::BudgetsController < Api::V1::BaseController + include Pagy::Backend + + before_action :ensure_read_scope + before_action :set_budget, only: :show + + def index + budgets_query = apply_filters(budgets_scope).order(start_date: :desc) + @per_page = safe_per_page_param + + @pagy, @budgets = pagy( + budgets_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + end + + def show + render :show + end + + private + + def set_budget + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + + @budget = budgets_scope.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def apply_filters(query) + query = query.where("budgets.start_date >= ?", parse_date_param(:start_date)) if params[:start_date].present? + query = query.where("budgets.end_date <= ?", parse_date_param(:end_date)) if params[:end_date].present? + query + end + + def budgets_scope + current_resource_owner.family.budgets.includes(budget_categories: :category) + end +end diff --git a/app/views/api/v1/budget_categories/_budget_category.json.jbuilder b/app/views/api/v1/budget_categories/_budget_category.json.jbuilder new file mode 100644 index 000000000..a0418f78a --- /dev/null +++ b/app/views/api/v1/budget_categories/_budget_category.json.jbuilder @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +money_to_minor_units = lambda do |money| + (money.amount * money.currency.minor_unit_conversion).round(0).to_i if money +end +include_derived_amounts = local_assigns.fetch(:include_derived_amounts, true) + +json.id budget_category.id +json.budget_id budget_category.budget_id +json.currency budget_category.currency +json.subcategory budget_category.subcategory? +json.inherits_parent_budget budget_category.inherits_parent_budget? + +json.budgeted_spending budget_category.budgeted_spending_money.format +json.budgeted_spending_cents money_to_minor_units.call(budget_category.budgeted_spending_money) +json.display_budgeted_spending Money.new(budget_category.display_budgeted_spending, budget_category.currency).format +json.display_budgeted_spending_cents money_to_minor_units.call(Money.new(budget_category.display_budgeted_spending, budget_category.currency)) +if include_derived_amounts + json.actual_spending budget_category.actual_spending_money.format + json.actual_spending_cents money_to_minor_units.call(budget_category.actual_spending_money) + json.available_to_spend budget_category.available_to_spend_money.format + json.available_to_spend_cents money_to_minor_units.call(budget_category.available_to_spend_money) +end + +json.category do + json.id budget_category.category.id + json.name budget_category.category.name + json.color budget_category.category.color + json.lucide_icon budget_category.category.lucide_icon + json.parent_id budget_category.category.parent_id +end + +json.created_at budget_category.created_at.iso8601 +json.updated_at budget_category.updated_at.iso8601 diff --git a/app/views/api/v1/budget_categories/index.json.jbuilder b/app/views/api/v1/budget_categories/index.json.jbuilder new file mode 100644 index 000000000..14ef80310 --- /dev/null +++ b/app/views/api/v1/budget_categories/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.budget_categories @budget_categories do |budget_category| + json.partial! "budget_category", budget_category: budget_category, include_derived_amounts: false +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/budget_categories/show.json.jbuilder b/app/views/api/v1/budget_categories/show.json.jbuilder new file mode 100644 index 000000000..84a92e5c0 --- /dev/null +++ b/app/views/api/v1/budget_categories/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "budget_category", budget_category: @budget_category diff --git a/app/views/api/v1/budgets/_budget.json.jbuilder b/app/views/api/v1/budgets/_budget.json.jbuilder new file mode 100644 index 000000000..173df442b --- /dev/null +++ b/app/views/api/v1/budgets/_budget.json.jbuilder @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +money_to_minor_units = lambda do |money| + (money.amount * money.currency.minor_unit_conversion).round(0).to_i if money +end + +include_derived_amounts = local_assigns.fetch(:include_derived_amounts, true) + +json.id budget.id +json.start_date budget.start_date +json.end_date budget.end_date +json.name budget.name +json.currency budget.currency +json.initialized budget.initialized? +json.current budget.current? + +json.budgeted_spending budget.budgeted_spending_money&.format +json.budgeted_spending_cents money_to_minor_units.call(budget.budgeted_spending_money) +json.expected_income budget.expected_income_money&.format +json.expected_income_cents money_to_minor_units.call(budget.expected_income_money) +json.allocated_spending budget.allocated_spending_money.format +json.allocated_spending_cents money_to_minor_units.call(budget.allocated_spending_money) + +if include_derived_amounts + json.actual_spending budget.actual_spending_money.format + json.actual_spending_cents money_to_minor_units.call(budget.actual_spending_money) + json.actual_income budget.actual_income_money.format + json.actual_income_cents money_to_minor_units.call(budget.actual_income_money) + json.available_to_spend budget.available_to_spend_money.format + json.available_to_spend_cents money_to_minor_units.call(budget.available_to_spend_money) + json.available_to_allocate budget.available_to_allocate_money.format + json.available_to_allocate_cents money_to_minor_units.call(budget.available_to_allocate_money) +end + +json.created_at budget.created_at.iso8601 +json.updated_at budget.updated_at.iso8601 diff --git a/app/views/api/v1/budgets/index.json.jbuilder b/app/views/api/v1/budgets/index.json.jbuilder new file mode 100644 index 000000000..ebf1b3018 --- /dev/null +++ b/app/views/api/v1/budgets/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.budgets @budgets do |budget| + json.partial! "budget", budget: budget, include_derived_amounts: false +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/budgets/show.json.jbuilder b/app/views/api/v1/budgets/show.json.jbuilder new file mode 100644 index 000000000..3e88d7669 --- /dev/null +++ b/app/views/api/v1/budgets/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "budget", budget: @budget diff --git a/config/routes.rb b/config/routes.rb index 310967f12..5fa7f90c7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -426,6 +426,8 @@ Rails.application.routes.draw do # Production API endpoints resources :accounts, only: [ :index, :show ] resources :balances, only: [ :index, :show ] + resources :budgets, only: [ :index, :show ] + resources :budget_categories, only: [ :index, :show ] resources :categories, only: [ :index, :show ] resources :merchants, only: %i[index show] resources :rules, only: [ :index, :show ] diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 626c8b5b8..b60bb7936 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -460,6 +460,276 @@ components: updated_at: type: string format: date-time + BudgetSummary: + type: object + required: + - id + - start_date + - end_date + - name + - currency + - initialized + - current + - created_at + - updated_at + properties: + id: + type: string + format: uuid + start_date: + type: string + format: date + end_date: + type: string + format: date + name: + type: string + currency: + type: string + initialized: + type: boolean + current: + type: boolean + budgeted_spending: + type: string + nullable: true + budgeted_spending_cents: + type: integer + nullable: true + expected_income: + type: string + nullable: true + expected_income_cents: + type: integer + nullable: true + allocated_spending: + type: string + allocated_spending_cents: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + Budget: + type: object + required: + - id + - start_date + - end_date + - name + - currency + - initialized + - current + - created_at + - updated_at + properties: + id: + type: string + format: uuid + start_date: + type: string + format: date + end_date: + type: string + format: date + name: + type: string + currency: + type: string + initialized: + type: boolean + current: + type: boolean + budgeted_spending: + type: string + nullable: true + budgeted_spending_cents: + type: integer + nullable: true + expected_income: + type: string + nullable: true + expected_income_cents: + type: integer + nullable: true + allocated_spending: + type: string + allocated_spending_cents: + type: integer + actual_spending: + type: string + actual_spending_cents: + type: integer + actual_income: + type: string + actual_income_cents: + type: integer + available_to_spend: + type: string + available_to_spend_cents: + type: integer + available_to_allocate: + type: string + available_to_allocate_cents: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + BudgetCollection: + type: object + required: + - budgets + - pagination + properties: + budgets: + type: array + items: + "$ref": "#/components/schemas/BudgetSummary" + pagination: + "$ref": "#/components/schemas/Pagination" + BudgetCategorySummary: + type: object + required: + - id + - budget_id + - currency + - subcategory + - inherits_parent_budget + - category + - created_at + - updated_at + properties: + id: + type: string + format: uuid + budget_id: + type: string + format: uuid + currency: + type: string + subcategory: + type: boolean + inherits_parent_budget: + type: boolean + budgeted_spending: + type: string + budgeted_spending_cents: + type: integer + display_budgeted_spending: + type: string + display_budgeted_spending_cents: + type: integer + category: + type: object + required: + - id + - name + - color + - lucide_icon + properties: + id: + type: string + format: uuid + name: + type: string + color: + type: string + lucide_icon: + type: string + parent_id: + type: string + format: uuid + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + BudgetCategory: + type: object + required: + - id + - budget_id + - currency + - subcategory + - inherits_parent_budget + - category + - created_at + - updated_at + properties: + id: + type: string + format: uuid + budget_id: + type: string + format: uuid + currency: + type: string + subcategory: + type: boolean + inherits_parent_budget: + type: boolean + budgeted_spending: + type: string + budgeted_spending_cents: + type: integer + display_budgeted_spending: + type: string + display_budgeted_spending_cents: + type: integer + actual_spending: + type: string + actual_spending_cents: + type: integer + available_to_spend: + type: string + available_to_spend_cents: + type: integer + category: + type: object + required: + - id + - name + - color + - lucide_icon + properties: + id: + type: string + format: uuid + name: + type: string + color: + type: string + lucide_icon: + type: string + parent_id: + type: string + format: uuid + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + BudgetCategoryCollection: + type: object + required: + - budget_categories + - pagination + properties: + budget_categories: + type: array + items: + "$ref": "#/components/schemas/BudgetCategorySummary" + pagination: + "$ref": "#/components/schemas/Pagination" Balance: type: object required: @@ -2774,6 +3044,220 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/budget_categories": + get: + summary: List budget categories + tags: + - Budget Categories + security: + - apiKeyAuth: [] + parameters: + - name: page + in: query + required: false + description: 'Page number (default: 1)' + schema: + type: integer + - name: per_page + in: query + required: false + description: 'Items per page (default: 25, max: 100)' + schema: + type: integer + - name: budget_id + in: query + required: false + schema: + type: string + format: uuid + description: Filter by budget ID + - name: category_id + in: query + required: false + schema: + type: string + format: uuid + description: Filter by category ID + - name: start_date + in: query + required: false + schema: + type: string + format: date + description: Filter budget categories whose budget starts on or after this + date + - name: end_date + in: query + required: false + schema: + type: string + format: date + description: Filter budget categories whose budget ends on or before this + date + responses: + '200': + description: budget categories listed + content: + application/json: + schema: + "$ref": "#/components/schemas/BudgetCategoryCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: invalid filter + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/budget_categories/{id}": + parameters: + - name: id + in: path + required: true + description: Budget category ID + schema: + type: string + format: uuid + get: + summary: Retrieve a budget category + tags: + - Budget Categories + security: + - apiKeyAuth: [] + responses: + '200': + description: budget category retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/BudgetCategory" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: budget category not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/budgets": + get: + summary: List budgets + tags: + - Budgets + security: + - apiKeyAuth: [] + parameters: + - name: page + in: query + required: false + description: 'Page number (default: 1)' + schema: + type: integer + - name: per_page + in: query + required: false + description: 'Items per page (default: 25, max: 100)' + schema: + type: integer + - name: start_date + in: query + required: false + schema: + type: string + format: date + description: Filter budgets starting on or after this date + - name: end_date + in: query + required: false + schema: + type: string + format: date + description: Filter budgets ending on or before this date + responses: + '200': + description: budgets listed + content: + application/json: + schema: + "$ref": "#/components/schemas/BudgetCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: invalid date filter + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/budgets/{id}": + parameters: + - name: id + in: path + required: true + description: Budget ID + schema: + type: string + format: uuid + get: + summary: Retrieve a budget + tags: + - Budgets + security: + - apiKeyAuth: [] + responses: + '200': + description: budget retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/Budget" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: budget not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/categories": get: summary: List categories diff --git a/spec/requests/api/v1/budget_categories_spec.rb b/spec/requests/api/v1/budget_categories_spec.rb new file mode 100644 index 000000000..a2c88159b --- /dev/null +++ b/spec/requests/api/v1/budget_categories_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Budget Categories', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y' + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Key', + key: key, + display_key: key, + scopes: %w[read], + source: 'web' + ) + end + + let(:api_key_without_read_scope) do + key = ApiKey.generate_secure_key + ApiKey.new( + user: user, + name: 'No Read Docs Key', + key: key, + display_key: key, + scopes: [], + source: 'mobile' + ).tap { |api_key| api_key.save!(validate: false) } + end + + let(:'X-Api-Key') { api_key.plain_key } + let!(:category) { family.categories.create!(name: 'Groceries', color: '#22c55e') } + let!(:budget) do + family.budgets.create!( + start_date: Date.current.beginning_of_month, + end_date: Date.current.end_of_month, + budgeted_spending: 3000, + expected_income: 5000, + currency: 'USD' + ) + end + let!(:budget_category) do + budget.budget_categories.create!( + category: category, + budgeted_spending: 500, + currency: 'USD' + ) + end + + path '/api/v1/budget_categories' do + get 'List budget categories' do + tags 'Budget Categories' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + parameter name: :page, in: :query, type: :integer, required: false, + description: 'Page number (default: 1)' + parameter name: :per_page, in: :query, type: :integer, required: false, + description: 'Items per page (default: 25, max: 100)' + parameter name: :budget_id, in: :query, required: false, + schema: { type: :string, format: :uuid }, + description: 'Filter by budget ID' + parameter name: :category_id, in: :query, required: false, + schema: { type: :string, format: :uuid }, + description: 'Filter by category ID' + parameter name: :start_date, in: :query, required: false, + schema: { type: :string, format: :date }, + description: 'Filter budget categories whose budget starts on or after this date' + parameter name: :end_date, in: :query, required: false, + schema: { type: :string, format: :date }, + description: 'Filter budget categories whose budget ends on or before this date' + + response '200', 'budget categories listed' do + schema '$ref' => '#/components/schemas/BudgetCategoryCollection' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '422', 'invalid filter' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:budget_id) { 'not-a-uuid' } + + run_test! + end + end + end + + path '/api/v1/budget_categories/{id}' do + parameter name: :id, in: :path, required: true, description: 'Budget category ID', + schema: { type: :string, format: :uuid } + + get 'Retrieve a budget category' do + tags 'Budget Categories' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { budget_category.id } + + response '200', 'budget category retrieved' do + schema '$ref' => '#/components/schemas/BudgetCategory' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '404', 'budget category not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/budgets_spec.rb b/spec/requests/api/v1/budgets_spec.rb new file mode 100644 index 000000000..e15bcdb85 --- /dev/null +++ b/spec/requests/api/v1/budgets_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Budgets', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y' + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Key', + key: key, + display_key: key, + scopes: %w[read], + source: 'web' + ) + end + + let(:api_key_without_read_scope) do + key = ApiKey.generate_secure_key + ApiKey.new( + user: user, + name: 'No Read Docs Key', + key: key, + display_key: key, + scopes: [], + source: 'mobile' + ).tap { |api_key| api_key.save!(validate: false) } + end + + let(:'X-Api-Key') { api_key.plain_key } + let!(:budget) do + family.budgets.create!( + start_date: Date.current.beginning_of_month, + end_date: Date.current.end_of_month, + budgeted_spending: 3000, + expected_income: 5000, + currency: 'USD' + ) + end + + path '/api/v1/budgets' do + get 'List budgets' do + tags 'Budgets' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + parameter name: :page, in: :query, type: :integer, required: false, + description: 'Page number (default: 1)' + parameter name: :per_page, in: :query, type: :integer, required: false, + description: 'Items per page (default: 25, max: 100)' + parameter name: :start_date, in: :query, required: false, + schema: { type: :string, format: :date }, + description: 'Filter budgets starting on or after this date' + parameter name: :end_date, in: :query, required: false, + schema: { type: :string, format: :date }, + description: 'Filter budgets ending on or before this date' + + response '200', 'budgets listed' do + schema '$ref' => '#/components/schemas/BudgetCollection' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '422', 'invalid date filter' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:start_date) { 'not-a-date' } + + run_test! + end + end + end + + path '/api/v1/budgets/{id}' do + parameter name: :id, in: :path, required: true, description: 'Budget ID', + schema: { type: :string, format: :uuid } + + get 'Retrieve a budget' do + tags 'Budgets' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { budget.id } + + response '200', 'budget retrieved' do + schema '$ref' => '#/components/schemas/Budget' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '404', 'budget not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index df7a5b56d..e63af7b90 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -297,6 +297,138 @@ RSpec.configure do |config| updated_at: { type: :string, format: :'date-time' } } }, + BudgetSummary: { + type: :object, + required: %w[id start_date end_date name currency initialized current created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + start_date: { type: :string, format: :date }, + end_date: { type: :string, format: :date }, + name: { type: :string }, + currency: { type: :string }, + initialized: { type: :boolean }, + current: { type: :boolean }, + budgeted_spending: { type: :string, nullable: true }, + budgeted_spending_cents: { type: :integer, nullable: true }, + expected_income: { type: :string, nullable: true }, + expected_income_cents: { type: :integer, nullable: true }, + allocated_spending: { type: :string }, + allocated_spending_cents: { type: :integer }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + Budget: { + type: :object, + required: %w[id start_date end_date name currency initialized current created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + start_date: { type: :string, format: :date }, + end_date: { type: :string, format: :date }, + name: { type: :string }, + currency: { type: :string }, + initialized: { type: :boolean }, + current: { type: :boolean }, + budgeted_spending: { type: :string, nullable: true }, + budgeted_spending_cents: { type: :integer, nullable: true }, + expected_income: { type: :string, nullable: true }, + expected_income_cents: { type: :integer, nullable: true }, + allocated_spending: { type: :string }, + allocated_spending_cents: { type: :integer }, + actual_spending: { type: :string }, + actual_spending_cents: { type: :integer }, + actual_income: { type: :string }, + actual_income_cents: { type: :integer }, + available_to_spend: { type: :string }, + available_to_spend_cents: { type: :integer }, + available_to_allocate: { type: :string }, + available_to_allocate_cents: { type: :integer }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + BudgetCollection: { + type: :object, + required: %w[budgets pagination], + properties: { + budgets: { + type: :array, + items: { '$ref' => '#/components/schemas/BudgetSummary' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' } + } + }, + BudgetCategorySummary: { + type: :object, + required: %w[id budget_id currency subcategory inherits_parent_budget category created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + budget_id: { type: :string, format: :uuid }, + currency: { type: :string }, + subcategory: { type: :boolean }, + inherits_parent_budget: { type: :boolean }, + budgeted_spending: { type: :string }, + budgeted_spending_cents: { type: :integer }, + display_budgeted_spending: { type: :string }, + display_budgeted_spending_cents: { type: :integer }, + category: { + type: :object, + required: %w[id name color lucide_icon], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string }, + color: { type: :string }, + lucide_icon: { type: :string }, + parent_id: { type: :string, format: :uuid, nullable: true } + } + }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + BudgetCategory: { + type: :object, + required: %w[id budget_id currency subcategory inherits_parent_budget category created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + budget_id: { type: :string, format: :uuid }, + currency: { type: :string }, + subcategory: { type: :boolean }, + inherits_parent_budget: { type: :boolean }, + budgeted_spending: { type: :string }, + budgeted_spending_cents: { type: :integer }, + display_budgeted_spending: { type: :string }, + display_budgeted_spending_cents: { type: :integer }, + actual_spending: { type: :string }, + actual_spending_cents: { type: :integer }, + available_to_spend: { type: :string }, + available_to_spend_cents: { type: :integer }, + category: { + type: :object, + required: %w[id name color lucide_icon], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string }, + color: { type: :string }, + lucide_icon: { type: :string }, + parent_id: { type: :string, format: :uuid, nullable: true } + } + }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + BudgetCategoryCollection: { + type: :object, + required: %w[budget_categories pagination], + properties: { + budget_categories: { + type: :array, + items: { '$ref' => '#/components/schemas/BudgetCategorySummary' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' } + } + }, Balance: { type: :object, required: %w[id date currency flows_factor balance balance_cents start_balance start_balance_cents end_balance end_balance_cents account created_at updated_at], diff --git a/test/controllers/api/v1/budget_categories_controller_test.rb b/test/controllers/api/v1/budget_categories_controller_test.rb new file mode 100644 index 000000000..0cd854b6a --- /dev/null +++ b/test/controllers/api/v1/budget_categories_controller_test.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::BudgetCategoriesControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @family = @user.family + @user.api_keys.active.destroy_all + + @api_key = ApiKey.create!( + user: @user, + name: "Test Read Key", + scopes: [ "read" ], + source: "web", + display_key: "test_read_#{SecureRandom.hex(8)}" + ) + + @budget = @family.budgets.create!( + start_date: 5.months.ago.beginning_of_month.to_date, + end_date: 5.months.ago.end_of_month.to_date, + budgeted_spending: 3000, + expected_income: 5000, + currency: "USD" + ) + @category = categories(:food_and_drink) + @budget_category = @budget.budget_categories.create!( + category: @category, + budgeted_spending: 500, + currency: "USD" + ) + + other_family = families(:empty) + other_category = other_family.categories.create!(name: "Other Food", color: "#123456") + other_budget = other_family.budgets.create!( + start_date: 6.months.ago.beginning_of_month.to_date, + end_date: 6.months.ago.end_of_month.to_date, + budgeted_spending: 1000, + expected_income: 2000, + currency: "USD" + ) + @other_budget_category = other_budget.budget_categories.create!( + category: other_category, + budgeted_spending: 100, + currency: "USD" + ) + end + + test "lists budget categories scoped to the current family" do + get api_v1_budget_categories_url, headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert response_data.key?("budget_categories") + assert response_data.key?("pagination") + assert_includes response_data["budget_categories"].map { |budget_category| budget_category["id"] }, @budget_category.id + assert_not_includes response_data["budget_categories"].map { |budget_category| budget_category["id"] }, @other_budget_category.id + + budget_category = response_data["budget_categories"].find { |category| category["id"] == @budget_category.id } + assert_kind_of Integer, budget_category["budgeted_spending_cents"] + assert_not budget_category.key?("actual_spending") + assert_not budget_category.key?("actual_spending_cents") + assert_not budget_category.key?("available_to_spend") + assert_not budget_category.key?("available_to_spend_cents") + end + + test "shows a budget category" do + get api_v1_budget_category_url(@budget_category), headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_equal @budget_category.id, response_data["id"] + assert_equal @budget.id, response_data["budget_id"] + assert_equal @category.id, response_data.dig("category", "id") + assert_kind_of Integer, response_data["budgeted_spending_cents"] + assert_kind_of Integer, response_data["actual_spending_cents"] + assert_kind_of Integer, response_data["available_to_spend_cents"] + end + + test "returns not found for another family's budget category" do + get api_v1_budget_category_url(@other_budget_category), headers: api_headers(@api_key) + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "returns not found for malformed budget category id" do + get api_v1_budget_category_url("not-a-uuid"), headers: api_headers(@api_key) + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "filters budget categories by budget_id" do + get api_v1_budget_categories_url, + params: { budget_id: @budget.id }, + headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_includes response_data["budget_categories"].map { |budget_category| budget_category["id"] }, @budget_category.id + end + + test "filters budget categories by category_id" do + get api_v1_budget_categories_url, + params: { category_id: @category.id }, + headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_includes response_data["budget_categories"].map { |budget_category| budget_category["id"] }, @budget_category.id + end + + test "rejects malformed budget_id filter" do + get api_v1_budget_categories_url, params: { budget_id: "not-a-uuid" }, headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + end + + test "rejects invalid date filters" do + get api_v1_budget_categories_url, params: { start_date: "03/01/2024" }, headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + end + + test "requires authentication" do + get api_v1_budget_categories_url + + assert_response :unauthorized + end + + test "requires read scope" do + api_key_without_read = ApiKey.new( + user: @user, + name: "No Read Key", + scopes: [], + source: "mobile", + display_key: "no_read_#{SecureRandom.hex(8)}" + ) + api_key_without_read.save!(validate: false) + + get api_v1_budget_categories_url, headers: api_headers(api_key_without_read) + + assert_response :forbidden + ensure + api_key_without_read&.destroy + end +end diff --git a/test/controllers/api/v1/budgets_controller_test.rb b/test/controllers/api/v1/budgets_controller_test.rb new file mode 100644 index 000000000..b4c7252ee --- /dev/null +++ b/test/controllers/api/v1/budgets_controller_test.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::BudgetsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @family = @user.family + @user.api_keys.active.destroy_all + + @api_key = ApiKey.create!( + user: @user, + name: "Test Read Key", + scopes: [ "read" ], + source: "web", + display_key: "test_read_#{SecureRandom.hex(8)}" + ) + + @budget = @family.budgets.create!( + start_date: 3.months.ago.beginning_of_month.to_date, + end_date: 3.months.ago.end_of_month.to_date, + budgeted_spending: 3000, + expected_income: 5000, + currency: "USD" + ) + + category = categories(:food_and_drink) + @budget_category = @budget.budget_categories.create!( + category: category, + budgeted_spending: 500, + currency: "USD" + ) + + other_family = families(:empty) + @other_budget = other_family.budgets.create!( + start_date: 4.months.ago.beginning_of_month.to_date, + end_date: 4.months.ago.end_of_month.to_date, + budgeted_spending: 1000, + expected_income: 2000, + currency: "USD" + ) + end + + test "lists budgets scoped to the current family" do + get api_v1_budgets_url, headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert response_data.key?("budgets") + assert response_data.key?("pagination") + assert_includes response_data["budgets"].map { |budget| budget["id"] }, @budget.id + assert_not_includes response_data["budgets"].map { |budget| budget["id"] }, @other_budget.id + + budget_response = response_data["budgets"].find { |budget| budget["id"] == @budget.id } + %w[ + actual_spending + actual_spending_cents + actual_income + actual_income_cents + available_to_spend + available_to_spend_cents + available_to_allocate + available_to_allocate_cents + ].each do |derived_field| + assert_not budget_response.key?(derived_field), "Expected budget index to omit #{derived_field}" + end + end + + test "shows a budget" do + get api_v1_budget_url(@budget.id), headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_equal @budget.id, response_data["id"] + assert_equal @budget.start_date.to_s, response_data["start_date"] + assert_equal "USD", response_data["currency"] + assert_equal true, response_data["initialized"] + assert_kind_of Integer, response_data["budgeted_spending_cents"] + assert_kind_of Integer, response_data["actual_spending_cents"] + assert_kind_of Integer, response_data["actual_income_cents"] + assert_kind_of Integer, response_data["available_to_spend_cents"] + assert_kind_of Integer, response_data["available_to_allocate_cents"] + end + + test "returns not found for another family's budget" do + get api_v1_budget_url(@other_budget.id), headers: api_headers(@api_key) + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "returns not found for malformed budget id" do + get api_v1_budget_url("not-a-uuid"), headers: api_headers(@api_key) + + assert_response :not_found + response_data = JSON.parse(response.body) + assert_equal "record_not_found", response_data["error"] + end + + test "filters budgets by date range" do + get api_v1_budgets_url, + params: { start_date: @budget.start_date.to_s, end_date: @budget.end_date.to_s }, + headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_includes response_data["budgets"].map { |budget| budget["id"] }, @budget.id + end + + test "rejects invalid date filters" do + get api_v1_budgets_url, params: { start_date: "03/01/2024" }, headers: api_headers(@api_key) + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert_equal "validation_failed", response_data["error"] + end + + test "requires authentication" do + get api_v1_budgets_url + + assert_response :unauthorized + end + + test "requires read scope" do + api_key_without_read = ApiKey.new( + user: @user, + name: "No Read Key", + scopes: [], + source: "mobile", + display_key: "no_read_#{SecureRandom.hex(8)}" + ) + api_key_without_read.save!(validate: false) + + get api_v1_budgets_url, headers: api_headers(api_key_without_read) + + assert_response :forbidden + ensure + api_key_without_read&.destroy + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 049641229..8eb66de7e 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -96,6 +96,10 @@ module ActiveSupport yield end + def api_headers(api_key) + { "X-Api-Key" => api_key.plain_key } + end + def user_password_test "maybetestpassword817983172" end