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
This commit is contained in:
ghost
2026-05-06 12:50:46 -06:00
committed by GitHub
parent 4b93bdb447
commit 2d38cfb011
18 changed files with 1442 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! "budget_category", budget_category: @budget_category

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! "budget", budget: @budget

View File

@@ -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 ]

View File

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

View File

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

View File

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

View File

@@ -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],

View File

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

View File

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

View File

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