diff --git a/app/controllers/api/v1/balances_controller.rb b/app/controllers/api/v1/balances_controller.rb new file mode 100644 index 000000000..0e08d030a --- /dev/null +++ b/app/controllers/api/v1/balances_controller.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class Api::V1::BalancesController < Api::V1::BaseController + include Pagy::Backend + + before_action :ensure_read_scope + before_action :set_balance, only: :show + helper_method :format_money, :money_to_minor_units + + def index + balances_query = apply_filters(balances_scope).order(date: :desc, created_at: :desc) + @per_page = safe_per_page_param + + @pagy, @balances = pagy( + balances_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue InvalidFilterError => e + render json: { + error: "validation_failed", + message: e.message, + errors: [ e.message ] + }, status: :unprocessable_entity + end + + def show + render :show + end + + private + + def set_balance + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + + @balance = balances_scope.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def balances_scope + Balance + .joins(:account) + .where(accounts: { id: accessible_account_ids }) + .includes(:account) + end + + def accessible_account_ids + @accessible_account_ids ||= current_resource_owner.family.accounts.accessible_by(current_resource_owner).select(:id) + end + + def apply_filters(query) + if params[:account_id].present? + raise InvalidFilterError, "account_id must be a valid UUID" unless valid_uuid?(params[:account_id]) + + query = query.where(account_id: params[:account_id]) + end + + query = query.where(currency: params[:currency].to_s.upcase) if params[:currency].present? + query = query.where("balances.date >= ?", parse_date_param(:start_date)) if params[:start_date].present? + query = query.where("balances.date <= ?", parse_date_param(:end_date)) if params[:end_date].present? + query + end + + def format_money(money) + money&.format + end + + def money_to_minor_units(money) + (money.amount * money.currency.minor_unit_conversion).round(0).to_i if money + end +end diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index dccaae77c..800c02a05 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -6,6 +6,8 @@ class Api::V1::BaseController < ApplicationController UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i private_constant :UUID_PATTERN + InvalidFilterError = Class.new(StandardError) + # Skip regular session-based authentication for API skip_authentication @@ -254,6 +256,12 @@ class Api::V1::BaseController < ApplicationController render_json({ error: "bad_request", message: "Required parameters are missing or invalid" }, status: :bad_request) end + def parse_date_param(key) + Date.iso8601(params[key].to_s) + rescue ArgumentError + raise InvalidFilterError, "#{key} must be an ISO 8601 date" + end + # Log API access for monitoring and debugging def log_api_access return unless current_resource_owner diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index 8c1992ba1..f5b9216b3 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -154,6 +154,39 @@ class Family::DataExporter }.to_json end + Balance.joins(:account) + .where(accounts: { family_id: @family.id }) + .chronological + .each do |balance| + lines << { + type: "Balance", + data: { + id: balance.id, + account_id: balance.account_id, + date: balance.date, + balance: balance.balance, + currency: balance.currency, + cash_balance: balance.cash_balance, + start_cash_balance: balance.start_cash_balance, + start_non_cash_balance: balance.start_non_cash_balance, + cash_inflows: balance.cash_inflows, + cash_outflows: balance.cash_outflows, + non_cash_inflows: balance.non_cash_inflows, + non_cash_outflows: balance.non_cash_outflows, + net_market_flows: balance.net_market_flows, + cash_adjustments: balance.cash_adjustments, + non_cash_adjustments: balance.non_cash_adjustments, + flows_factor: balance.flows_factor, + start_balance: balance.start_balance, + end_cash_balance: balance.end_cash_balance, + end_non_cash_balance: balance.end_non_cash_balance, + end_balance: balance.end_balance, + created_at: balance.created_at, + updated_at: balance.updated_at + } + }.to_json + end + # Export categories @family.categories.find_each do |category| lines << { diff --git a/app/views/api/v1/balances/_balance.json.jbuilder b/app/views/api/v1/balances/_balance.json.jbuilder new file mode 100644 index 000000000..05b92d98e --- /dev/null +++ b/app/views/api/v1/balances/_balance.json.jbuilder @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +json.id balance.id +json.date balance.date +json.currency balance.currency +json.flows_factor balance.flows_factor + +json.balance format_money(balance.balance_money) +json.balance_cents money_to_minor_units(balance.balance_money) +json.cash_balance format_money(balance.cash_balance_money) +json.cash_balance_cents money_to_minor_units(balance.cash_balance_money) + +json.start_cash_balance format_money(balance.start_cash_balance_money) +json.start_cash_balance_cents money_to_minor_units(balance.start_cash_balance_money) +json.start_non_cash_balance format_money(balance.start_non_cash_balance_money) +json.start_non_cash_balance_cents money_to_minor_units(balance.start_non_cash_balance_money) +json.start_balance format_money(balance.start_balance_money) +json.start_balance_cents money_to_minor_units(balance.start_balance_money) + +json.cash_inflows format_money(balance.cash_inflows_money) +json.cash_inflows_cents money_to_minor_units(balance.cash_inflows_money) +json.cash_outflows format_money(balance.cash_outflows_money) +json.cash_outflows_cents money_to_minor_units(balance.cash_outflows_money) +json.non_cash_inflows format_money(balance.non_cash_inflows_money) +json.non_cash_inflows_cents money_to_minor_units(balance.non_cash_inflows_money) +json.non_cash_outflows format_money(balance.non_cash_outflows_money) +json.non_cash_outflows_cents money_to_minor_units(balance.non_cash_outflows_money) +json.net_market_flows format_money(balance.net_market_flows_money) +json.net_market_flows_cents money_to_minor_units(balance.net_market_flows_money) +json.cash_adjustments format_money(balance.cash_adjustments_money) +json.cash_adjustments_cents money_to_minor_units(balance.cash_adjustments_money) +json.non_cash_adjustments format_money(balance.non_cash_adjustments_money) +json.non_cash_adjustments_cents money_to_minor_units(balance.non_cash_adjustments_money) + +json.end_cash_balance format_money(balance.end_cash_balance_money) +json.end_cash_balance_cents money_to_minor_units(balance.end_cash_balance_money) +json.end_non_cash_balance format_money(balance.end_non_cash_balance_money) +json.end_non_cash_balance_cents money_to_minor_units(balance.end_non_cash_balance_money) +json.end_balance format_money(balance.end_balance_money) +json.end_balance_cents money_to_minor_units(balance.end_balance_money) + +json.account do + json.id balance.account.id + json.name balance.account.name + json.account_type balance.account.accountable_type&.underscore +end + +json.created_at balance.created_at.iso8601 +json.updated_at balance.updated_at.iso8601 diff --git a/app/views/api/v1/balances/index.json.jbuilder b/app/views/api/v1/balances/index.json.jbuilder new file mode 100644 index 000000000..ddd8fe389 --- /dev/null +++ b/app/views/api/v1/balances/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.balances @balances do |balance| + json.partial! "balance", balance: balance +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/balances/show.json.jbuilder b/app/views/api/v1/balances/show.json.jbuilder new file mode 100644 index 000000000..3c31c5911 --- /dev/null +++ b/app/views/api/v1/balances/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "balance", balance: @balance diff --git a/config/routes.rb b/config/routes.rb index 11105021b..310967f12 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -425,6 +425,7 @@ Rails.application.routes.draw do # Production API endpoints resources :accounts, only: [ :index, :show ] + resources :balances, 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 74606c0df..626c8b5b8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -460,6 +460,146 @@ components: updated_at: type: string format: date-time + Balance: + type: object + required: + - id + - date + - currency + - flows_factor + - balance + - balance_cents + - start_balance + - start_balance_cents + - end_balance + - end_balance_cents + - account + - created_at + - updated_at + properties: + id: + type: string + format: uuid + date: + type: string + format: date + currency: + type: string + flows_factor: + type: number + format: float + balance: + type: string + balance_cents: + type: integer + description: Balance in currency minor units + cash_balance: + type: string + nullable: true + cash_balance_cents: + type: integer + nullable: true + description: Cash balance in currency minor units + start_cash_balance: + type: string + start_cash_balance_cents: + type: integer + description: Starting cash balance in currency minor units + start_non_cash_balance: + type: string + start_non_cash_balance_cents: + type: integer + description: Starting non-cash balance in currency minor units + start_balance: + type: string + start_balance_cents: + type: integer + description: Starting total balance in currency minor units + cash_inflows: + type: string + cash_inflows_cents: + type: integer + description: Cash inflows in currency minor units + cash_outflows: + type: string + cash_outflows_cents: + type: integer + description: Cash outflows in currency minor units + non_cash_inflows: + type: string + non_cash_inflows_cents: + type: integer + description: Non-cash inflows in currency minor units + non_cash_outflows: + type: string + non_cash_outflows_cents: + type: integer + description: Non-cash outflows in currency minor units + net_market_flows: + type: string + net_market_flows_cents: + type: integer + description: Net market flows in currency minor units + cash_adjustments: + type: string + cash_adjustments_cents: + type: integer + description: Cash adjustments in currency minor units + non_cash_adjustments: + type: string + non_cash_adjustments_cents: + type: integer + description: Non-cash adjustments in currency minor units + end_cash_balance: + type: string + end_cash_balance_cents: + type: integer + description: Ending cash balance in currency minor units + end_non_cash_balance: + type: string + end_non_cash_balance_cents: + type: integer + description: Ending non-cash balance in currency minor units + end_balance: + type: string + end_balance_cents: + type: integer + description: Ending total balance in currency minor units + account: + "$ref": "#/components/schemas/BalanceAccount" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + BalanceAccount: + type: object + required: + - id + - name + - account_type + properties: + id: + type: string + format: uuid + name: + type: string + account_type: + type: string + nullable: true + BalanceCollection: + type: object + required: + - balances + - pagination + properties: + balances: + type: array + items: + "$ref": "#/components/schemas/Balance" + pagination: + "$ref": "#/components/schemas/Pagination" Category: type: object required: @@ -2522,6 +2662,118 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/balances": + get: + summary: List balance history records + tags: + - Balances + 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: account_id + in: query + required: false + description: Filter by account ID + schema: + type: string + format: uuid + - name: currency + in: query + required: false + description: Filter by currency code + schema: + type: string + - name: start_date + in: query + required: false + description: Filter balances from this date + schema: + type: string + format: date + - name: end_date + in: query + required: false + description: Filter balances until this date + schema: + type: string + format: date + responses: + '200': + description: balances listed + content: + application/json: + schema: + "$ref": "#/components/schemas/BalanceCollection" + '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/balances/{id}": + parameters: + - name: id + in: path + required: true + description: Balance ID + schema: + type: string + format: uuid + get: + summary: Retrieve a balance history record + tags: + - Balances + security: + - apiKeyAuth: [] + responses: + '200': + description: balance retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/Balance" + '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: balance not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/categories": get: summary: List categories diff --git a/spec/requests/api/v1/balances_spec.rb b/spec/requests/api/v1/balances_spec.rb new file mode 100644 index 000000000..641b76a5b --- /dev/null +++ b/spec/requests/api/v1/balances_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Balances', 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, + scopes: %w[read_write], + 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, + scopes: %w[write], + source: 'web' + ).tap { |api_key| api_key.save!(validate: false) } + end + + let(:'X-Api-Key') { api_key.plain_key } + + let!(:account) do + Account.create!( + family: family, + name: 'Checking Account', + balance: 1500.50, + currency: 'USD', + accountable: Depository.create! + ) + end + + let!(:balance) do + account.balances.create!( + date: Date.parse('2024-01-15'), + balance: 1500.50, + cash_balance: 1500.50, + start_cash_balance: 1200, + start_non_cash_balance: 0, + cash_inflows: 300.50, + cash_outflows: 0, + currency: 'USD' + ) + end + + path '/api/v1/balances' do + get 'List balance history records' do + tags 'Balances' + 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: :account_id, in: :query, required: false, + description: 'Filter by account ID', + schema: { type: :string, format: :uuid } + parameter name: :currency, in: :query, required: false, + description: 'Filter by currency code', + schema: { type: :string } + parameter name: :start_date, in: :query, required: false, + description: 'Filter balances from this date', + schema: { type: :string, format: :date } + parameter name: :end_date, in: :query, required: false, + description: 'Filter balances until this date', + schema: { type: :string, format: :date } + + response '200', 'balances listed' do + schema '$ref' => '#/components/schemas/BalanceCollection' + + 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(:account_id) { 'not-a-uuid' } + + run_test! + end + end + end + + path '/api/v1/balances/{id}' do + parameter name: :id, in: :path, required: true, description: 'Balance ID', + schema: { type: :string, format: :uuid } + + get 'Retrieve a balance history record' do + tags 'Balances' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { balance.id } + + response '200', 'balance retrieved' do + schema '$ref' => '#/components/schemas/Balance' + + 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', 'balance 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 e6976f4b5..df7a5b56d 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -297,6 +297,69 @@ RSpec.configure do |config| updated_at: { type: :string, format: :'date-time' } } }, + 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], + properties: { + id: { type: :string, format: :uuid }, + date: { type: :string, format: :date }, + currency: { type: :string }, + flows_factor: { type: :number, format: :float }, + balance: { type: :string }, + balance_cents: { type: :integer, description: 'Balance in currency minor units' }, + cash_balance: { type: :string, nullable: true }, + cash_balance_cents: { type: :integer, nullable: true, description: 'Cash balance in currency minor units' }, + start_cash_balance: { type: :string }, + start_cash_balance_cents: { type: :integer, description: 'Starting cash balance in currency minor units' }, + start_non_cash_balance: { type: :string }, + start_non_cash_balance_cents: { type: :integer, description: 'Starting non-cash balance in currency minor units' }, + start_balance: { type: :string }, + start_balance_cents: { type: :integer, description: 'Starting total balance in currency minor units' }, + cash_inflows: { type: :string }, + cash_inflows_cents: { type: :integer, description: 'Cash inflows in currency minor units' }, + cash_outflows: { type: :string }, + cash_outflows_cents: { type: :integer, description: 'Cash outflows in currency minor units' }, + non_cash_inflows: { type: :string }, + non_cash_inflows_cents: { type: :integer, description: 'Non-cash inflows in currency minor units' }, + non_cash_outflows: { type: :string }, + non_cash_outflows_cents: { type: :integer, description: 'Non-cash outflows in currency minor units' }, + net_market_flows: { type: :string }, + net_market_flows_cents: { type: :integer, description: 'Net market flows in currency minor units' }, + cash_adjustments: { type: :string }, + cash_adjustments_cents: { type: :integer, description: 'Cash adjustments in currency minor units' }, + non_cash_adjustments: { type: :string }, + non_cash_adjustments_cents: { type: :integer, description: 'Non-cash adjustments in currency minor units' }, + end_cash_balance: { type: :string }, + end_cash_balance_cents: { type: :integer, description: 'Ending cash balance in currency minor units' }, + end_non_cash_balance: { type: :string }, + end_non_cash_balance_cents: { type: :integer, description: 'Ending non-cash balance in currency minor units' }, + end_balance: { type: :string }, + end_balance_cents: { type: :integer, description: 'Ending total balance in currency minor units' }, + account: { '$ref' => '#/components/schemas/BalanceAccount' }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + BalanceAccount: { + type: :object, + required: %w[id name account_type], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string }, + account_type: { type: :string, nullable: true } + } + }, + BalanceCollection: { + type: :object, + required: %w[balances pagination], + properties: { + balances: { + type: :array, + items: { '$ref' => '#/components/schemas/Balance' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' } + } + }, Category: { type: :object, required: %w[id name color icon], diff --git a/test/controllers/api/v1/balances_controller_test.rb b/test/controllers/api/v1/balances_controller_test.rb new file mode 100644 index 000000000..afb2cb201 --- /dev/null +++ b/test/controllers/api/v1/balances_controller_test.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::BalancesControllerTest < 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)}" + ) + + @account = @family.accounts.create!( + name: "Balance Checking", + accountable: Depository.new, + balance: 1234.56, + currency: "USD" + ) + @balance = @account.balances.create!( + date: Date.parse("2024-01-15"), + balance: 1234.56, + cash_balance: 1234.56, + start_cash_balance: 1000, + start_non_cash_balance: 0, + cash_inflows: 234.56, + cash_outflows: 0, + currency: "USD" + ) + + other_family = families(:empty) + other_account = other_family.accounts.create!( + name: "Other Balance Checking", + accountable: Depository.new, + balance: 500, + currency: "USD" + ) + @other_balance = other_account.balances.create!( + date: Date.parse("2024-01-15"), + balance: 500, + cash_balance: 500, + currency: "USD" + ) + end + + test "lists balances scoped to accessible family accounts" do + get api_v1_balances_url, headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert response_data.key?("balances") + assert response_data.key?("pagination") + assert_includes response_data["balances"].map { |balance| balance["id"] }, @balance.id + assert_not_includes response_data["balances"].map { |balance| balance["id"] }, @other_balance.id + end + + test "shows a balance" do + get api_v1_balance_url(@balance), headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_equal @balance.id, response_data["id"] + assert_equal "2024-01-15", response_data["date"] + assert_equal @account.id, response_data.dig("account", "id") + assert_kind_of Integer, response_data["balance_cents"] + assert_kind_of Integer, response_data["end_balance_cents"] + end + + test "renders nullable cash balance fields" do + balance_without_cash = @account.balances.create!( + date: Date.parse("2024-01-16"), + balance: 1234.56, + currency: "USD" + ) + balance_without_cash.update_column(:cash_balance, nil) + + get api_v1_balance_url(balance_without_cash), headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_nil response_data["cash_balance"] + assert_nil response_data["cash_balance_cents"] + end + + test "renders nullable account type" do + @account.update_columns(accountable_type: nil, accountable_id: nil) + + get api_v1_balance_url(@balance), headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_nil response_data.dig("account", "account_type") + end + + test "returns not found for another family's balance" do + get api_v1_balance_url(@other_balance), 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 balance id" do + get api_v1_balance_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 balances by account_id" do + get api_v1_balances_url, + params: { account_id: @account.id }, + headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_includes response_data["balances"].map { |balance| balance["id"] }, @balance.id + end + + test "filters balances by currency" do + eur_balance = @account.balances.create!( + date: Date.parse("2024-01-16"), + balance: 100, + currency: "EUR" + ) + + get api_v1_balances_url, + params: { currency: "usd" }, + headers: api_headers(@api_key) + + assert_response :success + balance_ids = JSON.parse(response.body)["balances"].map { |balance| balance["id"] } + assert_includes balance_ids, @balance.id + assert_not_includes balance_ids, eur_balance.id + end + + test "filters balances by date range" do + get api_v1_balances_url, + params: { start_date: "2024-01-15", end_date: "2024-01-15" }, + headers: api_headers(@api_key) + + assert_response :success + response_data = JSON.parse(response.body) + assert_includes response_data["balances"].map { |balance| balance["id"] }, @balance.id + end + + test "rejects malformed account_id filter" do + get api_v1_balances_url, params: { account_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_balances_url, params: { start_date: "01/15/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_balances_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_balances_url, headers: api_headers(api_key_without_read) + + assert_response :forbidden + ensure + api_key_without_read&.destroy + end + + private + + def api_headers(api_key) + { "X-Api-Key" => api_key.display_key } + end +end diff --git a/test/models/family/data_exporter_test.rb b/test/models/family/data_exporter_test.rb index d25575ae4..19d2282f8 100644 --- a/test/models/family/data_exporter_test.rb +++ b/test/models/family/data_exporter_test.rb @@ -376,6 +376,50 @@ class Family::DataExporterTest < ActiveSupport::TestCase end end + test "exports balance history in NDJSON for backup verification" do + balance = @account.balances.create!( + date: Date.parse("2024-01-15"), + balance: 1234.56, + cash_balance: 1234.56, + start_cash_balance: 1000, + start_non_cash_balance: 0, + cash_inflows: 234.56, + cash_outflows: 0, + flows_factor: 1, + currency: "USD" + ) + + zip_data = @exporter.generate_export + + Zip::File.open_buffer(zip_data) do |zip| + ndjson_records = zip.read("all.ndjson").split("\n").map { |line| JSON.parse(line) } + balance_data = ndjson_records.find { |record| record["type"] == "Balance" && record.dig("data", "id") == balance.id } + + assert balance_data + assert_equal @account.id, balance_data["data"]["account_id"] + assert_equal "2024-01-15", balance_data["data"]["date"] + assert_equal "1234.56", BigDecimal(balance_data["data"]["balance"].to_s).to_s("F") + assert_equal "USD", balance_data["data"]["currency"] + end + end + + test "exports balance history chronologically" do + @account.balances.create!(date: Date.parse("2024-03-01"), balance: 300, flows_factor: 1, currency: "USD") + @account.balances.create!(date: Date.parse("2024-01-01"), balance: 100, flows_factor: 1, currency: "USD") + + zip_data = @exporter.generate_export + + Zip::File.open_buffer(zip_data) do |zip| + balance_dates = zip.read("all.ndjson") + .split("\n") + .map { |line| JSON.parse(line) } + .select { |record| record["type"] == "Balance" } + .map { |record| Date.iso8601(record.dig("data", "date")) } + + assert_equal balance_dates.sort, balance_dates + end + end + test "exports holding snapshots in NDJSON" do investment_account = @family.accounts.create!( name: "Investment Account",