diff --git a/app/controllers/api/v1/trades_controller.rb b/app/controllers/api/v1/trades_controller.rb index 0538fe321..fc26fb3e3 100644 --- a/app/controllers/api/v1/trades_controller.rb +++ b/app/controllers/api/v1/trades_controller.rb @@ -62,16 +62,27 @@ class Api::V1::TradesController < Api::V1::BaseController model.lock_saved_attributes! model.mark_user_modified! model.sync_account_later - @trade = model.trade + + if model.entryable.is_a?(Transaction) + @transaction = model.entryable + render template: "api/v1/transactions/show", status: :created + else + @trade = model.trade + apply_trade_create_options! + return if performed? + @entry = @trade.entry + render :show, status: :created + end + elsif model.is_a?(Transfer) + @transfer = model + render template: "api/v1/transfers/show", status: :created else @trade = model + apply_trade_create_options! + return if performed? + @entry = @trade.entry + render :show, status: :created end - - apply_trade_create_options! - return if performed? - - @entry = @trade.entry - render :show, status: :created rescue ActiveRecord::RecordNotFound => e message = (e.model == "Account") ? "Account not found" : "Security not found" render json: { error: "not_found", message: message }, status: :not_found @@ -146,7 +157,8 @@ class Api::V1::TradesController < Api::V1::BaseController def trade_params params.require(:trade).permit( :account_id, :date, :qty, :price, :currency, - :security_id, :ticker, :manual_ticker, :investment_activity_label, :category_id + :security_id, :ticker, :manual_ticker, :investment_activity_label, :category_id, + :fee, :type, :amount, :transfer_account_id ) end @@ -202,11 +214,70 @@ class Api::V1::TradesController < Api::V1::BaseController def build_create_form_params(account) type = params.dig(:trade, :type).to_s.downcase - unless %w[buy sell].include?(type) - render_validation_error("Type must be buy or sell", [ "type must be 'buy' or 'sell'" ]) + unless %w[buy sell dividend deposit withdrawal interest].include?(type) + render_validation_error("Invalid type", [ "type must be 'buy', 'sell', 'dividend', 'deposit', 'withdrawal', or 'interest'" ]) return nil end + case type + when "deposit", "withdrawal" + unless trade_params[:amount].present? + render_validation_error("Amount is required", [ "amount must be present for deposit/withdrawal" ]) + return nil + end + + unless trade_params[:date].present? + render_validation_error("Date is required", [ "date must be present" ]) + return nil + end + + { + account: account, + date: trade_params[:date], + amount: parse_positive_amount!(trade_params[:amount], context: "deposit/withdrawal"), + currency: trade_params[:currency].presence || account.currency, + type: type, + transfer_account_id: trade_params[:transfer_account_id] + }.compact + + when "interest" + unless trade_params[:date].present? + render_validation_error("Date is required", [ "date must be present" ]) + return nil + end + + unless trade_params[:amount].present? + render_validation_error("Amount is required", [ "amount must be present" ]) + return nil + end + + ticker_value = nil + manual_ticker_value = nil + if trade_params[:ticker].present? + ticker_value = trade_params[:ticker] + elsif trade_params[:manual_ticker].present? + manual_ticker_value = trade_params[:manual_ticker] + end + + { + account: account, + date: trade_params[:date], + amount: parse_positive_amount!(trade_params[:amount], context: "interest"), + currency: trade_params[:currency].presence || account.currency, + type: type, + ticker: ticker_value, + manual_ticker: manual_ticker_value + }.compact + + when "dividend" + build_dividend_params(account) + + else + build_investment_trade_params(account) + end + end + + def build_investment_trade_params(account) ticker_value = nil manual_ticker_value = nil @@ -245,8 +316,46 @@ class Api::V1::TradesController < Api::V1::BaseController date: trade_params[:date], qty: qty, price: price, + fee: trade_params[:fee].to_d, currency: trade_params[:currency].presence || account.currency, - type: type, + type: trade_params[:type].to_s.downcase, + ticker: ticker_value, + manual_ticker: manual_ticker_value + }.compact + end + + def build_dividend_params(account) + ticker_value = nil + manual_ticker_value = nil + + unless trade_params[:date].present? + render_validation_error("Date is required", [ "date must be present" ]) + return nil + end + + if trade_params[:security_id].present? + security = Security.find(trade_params[:security_id]) + ticker_value = security.exchange_operating_mic.present? ? "#{security.ticker}|#{security.exchange_operating_mic}" : security.ticker + elsif trade_params[:ticker].present? + ticker_value = trade_params[:ticker] + elsif trade_params[:manual_ticker].present? + manual_ticker_value = trade_params[:manual_ticker] + else + render_validation_error("Security identifier required", [ "Provide security_id, ticker, or manual_ticker" ]) + return nil + end + + unless trade_params[:amount].present? + render_validation_error("Amount is required", [ "amount must be present for dividend" ]) + return nil + end + + { + account: account, + date: trade_params[:date], + amount: parse_positive_amount!(trade_params[:amount], context: "dividend"), + currency: trade_params[:currency].presence || account.currency, + type: "dividend", ticker: ticker_value, manual_ticker: manual_ticker_value }.compact @@ -296,10 +405,21 @@ class Api::V1::TradesController < Api::V1::BaseController }, status: :internal_server_error end + def parse_positive_amount!(raw, context:) + value_raw = raw.to_s.strip + return render_validation_error("Amount is required", [ "amount must be present for #{context}" ]) if value_raw.blank? + + value = value_raw.to_d + non_numeric = value.zero? && value_raw !~ /\A0(\.0*)?\z/ + return render_validation_error("Amount must be a valid number", [ "amount must be a valid positive number" ]) if non_numeric || value <= 0 + + value + end + def safe_page_param page = params[:page].to_i page > 0 ? page : 1 - end + end def safe_per_page_param per_page = params[:per_page].to_i diff --git a/app/views/api/v1/transactions/show.json.jbuilder b/app/views/api/v1/transactions/show.json.jbuilder index 22dd4f8c8..248198dde 100644 --- a/app/views/api/v1/transactions/show.json.jbuilder +++ b/app/views/api/v1/transactions/show.json.jbuilder @@ -1,3 +1,3 @@ # frozen_string_literal: true -json.partial! "transaction", transaction: @transaction +json.partial! "api/v1/transactions/transaction", transaction: @transaction diff --git a/app/views/api/v1/transfers/show.json.jbuilder b/app/views/api/v1/transfers/show.json.jbuilder index d5c0710f3..cdb2c513d 100644 --- a/app/views/api/v1/transfers/show.json.jbuilder +++ b/app/views/api/v1/transfers/show.json.jbuilder @@ -1,3 +1,3 @@ # frozen_string_literal: true -json.partial! "transfer", transfer: @transfer +json.partial! "api/v1/transfers/transfer", transfer: @transfer diff --git a/config/brakeman.ignore b/config/brakeman.ignore index d6b23c8f8..70f290096 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -49,13 +49,13 @@ { "warning_type": "Mass Assignment", "warning_code": 105, - "fingerprint": "d770e95392c6c69b364dcc0c99faa1c8f4f0cceb085bcc55630213d0b7b8b87f", + "fingerprint": "81c63f2c375da309440b9308df3ae9d1fcbada7043a26919898b08f3a38b29f6", "check_name": "PermitAttributes", "message": "Potentially dangerous key allowed for mass assignment", "file": "app/controllers/api/v1/trades_controller.rb", - "line": 165, + "line": 159, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", - "code": "params.require(:trade).permit(:account_id, :date, :qty, :price, :currency, :security_id, :ticker, :manual_ticker, :investment_activity_label, :category_id)", + "code": "params.require(:trade).permit(:account_id, :date, :qty, :price, :currency, :security_id, :ticker, :manual_ticker, :investment_activity_label, :category_id, :fee, :type, :amount, :transfer_account_id)", "render_path": null, "location": { "type": "method", @@ -67,7 +67,7 @@ "cwe_id": [ 915 ], - "note": "account_id and security_id validated in create/update: account via family.accounts.find and supports_trades?, security via resolve_security" + "note": "account_id validated in create action (line 42: family.accounts.visible.find ensures family membership; lines 44-48: supports_trades? check); security_id validated via Security.find (line 290); transfer_account_id validated in Transfer::Creator where family.accounts.find scopes both source and destination accounts to the family (app/models/transfer/creator.rb lines 4-5); type validated in build_create_form_params against allowed values: buy, sell, dividend, deposit, withdrawal, interest (line 217)" }, { "warning_type": "Mass Assignment", diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a8f224d61..3b748f49d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1676,6 +1676,46 @@ components: properties: message: type: string + TransactionResponse: + type: object + required: + - id + - date + - amount + - currency + - name + - entryable_type + - account + properties: + id: + type: string + format: uuid + date: + type: string + format: date + amount: + type: string + currency: + type: string + name: + type: string + entryable_type: + type: string + account: + type: object + required: + - id + - name + - account_type + properties: + id: + type: string + format: uuid + name: + type: string + account_type: + type: string + nullable: true ImportConfiguration: type: object properties: @@ -6864,13 +6904,25 @@ paths: parameters: [] responses: '201': - description: trade created + description: interest created content: application/json: schema: - "$ref": "#/components/schemas/Trade" + "$ref": "#/components/schemas/TransactionResponse" + '403': + description: forbidden - api key missing read_write scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '401': + description: unauthorized - missing api key + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" '422': - description: validation error - missing security identifier + description: deposit without amount returns error content: application/json: schema: @@ -6900,15 +6952,23 @@ paths: description: Trade date (required) qty: type: number - description: Quantity (required) + description: Quantity (required for buy/sell) price: type: number - description: Price (required) + description: Price (required for buy/sell) + amount: + type: number + description: Amount (required for dividend, deposit, withdrawal, + interest) type: type: string enum: - buy - sell + - dividend + - deposit + - withdrawal + - interest description: Trade type (required) security_id: type: string @@ -6931,11 +6991,13 @@ paths: type: string format: uuid description: Category ID + transfer_account_id: + type: string + format: uuid + description: Destination/source account ID for linked transfers required: - account_id - date - - qty - - price - type required: - trade @@ -6987,6 +7049,18 @@ paths: application/json: schema: "$ref": "#/components/schemas/Trade" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: forbidden - api key missing read_write scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" '404': description: trade not found content: @@ -7014,6 +7088,10 @@ paths: enum: - buy - sell + - dividend + - deposit + - withdrawal + - interest nature: type: string enum: @@ -7044,6 +7122,18 @@ paths: application/json: schema: "$ref": "#/components/schemas/DeleteResponse" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: forbidden - api key missing read_write scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" '404': description: trade not found content: diff --git a/spec/requests/api/v1/trades_spec.rb b/spec/requests/api/v1/trades_spec.rb index 97a40a03a..2f58668e8 100644 --- a/spec/requests/api/v1/trades_spec.rb +++ b/spec/requests/api/v1/trades_spec.rb @@ -160,17 +160,19 @@ RSpec.describe 'API V1 Trades', type: :request do properties: { account_id: { type: :string, format: :uuid, description: 'Account ID (required)' }, date: { type: :string, format: :date, description: 'Trade date (required)' }, - qty: { type: :number, description: 'Quantity (required)' }, - price: { type: :number, description: 'Price (required)' }, - type: { type: :string, enum: %w[buy sell], description: 'Trade type (required)' }, + qty: { type: :number, description: 'Quantity (required for buy/sell)' }, + price: { type: :number, description: 'Price (required for buy/sell)' }, + amount: { type: :number, description: 'Amount (required for dividend, deposit, withdrawal, interest)' }, + type: { type: :string, enum: %w[buy sell dividend deposit withdrawal interest], description: 'Trade type (required)' }, security_id: { type: :string, format: :uuid, description: 'Security ID (one of security_id, ticker, manual_ticker required)' }, ticker: { type: :string, description: 'Ticker symbol' }, manual_ticker: { type: :string, description: 'Manual ticker for offline securities' }, currency: { type: :string, description: 'Currency (defaults to account currency)' }, investment_activity_label: { type: :string, description: 'Activity label (e.g. Buy, Sell)' }, - category_id: { type: :string, format: :uuid, description: 'Category ID' } + category_id: { type: :string, format: :uuid, description: 'Category ID' }, + transfer_account_id: { type: :string, format: :uuid, description: 'Destination/source account ID for linked transfers' } }, - required: %w[account_id date qty price type] + required: %w[account_id date type] } }, required: %w[trade] @@ -195,6 +197,51 @@ RSpec.describe 'API V1 Trades', type: :request do run_test! end + response '403', 'forbidden - api key missing read_write scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:read_only_api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Read Key', + key: key, + scopes: %w[read], + source: 'mobile' + ) + end + let(:'X-Api-Key') { read_only_api_key.plain_key } + + run_test! + end + + response '401', 'unauthorized - missing api key' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '422', 'invalid date format' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + trade: { + account_id: account.id, + date: 'invalid-date', + qty: 10, + price: 50, + type: 'buy', + security_id: security.id + } + } + end + + run_test! + end + response '422', 'account does not support trades' do schema '$ref' => '#/components/schemas/ErrorResponse' @@ -277,6 +324,145 @@ RSpec.describe 'API V1 Trades', type: :request do run_test! end + + response '201', 'dividend created with security' do + schema '$ref' => '#/components/schemas/Trade' + + let(:body) do + { + trade: { + account_id: account.id, + date: Date.current.to_s, + type: 'dividend', + amount: 25.50, + currency: 'USD', + ticker: 'AAPL' + } + } + end + + run_test! + end + + response '422', 'dividend without security returns error' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + trade: { + account_id: account.id, + date: Date.current.to_s, + type: 'dividend', + amount: 25.50 + } + } + end + + run_test! + end + + response '422', 'dividend without amount returns error' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + trade: { + account_id: account.id, + date: Date.current.to_s, + type: 'dividend', + ticker: 'AAPL' + } + } + end + + run_test! + end + + response '422', 'invalid type returns error' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + trade: { + account_id: account.id, + date: Date.current.to_s, + type: 'invalid' + } + } + end + + run_test! + end + + response '201', 'deposit created' do + schema '$ref' => '#/components/schemas/TransactionResponse' + + let(:body) do + { + trade: { + account_id: account.id, + date: Date.current.to_s, + type: 'deposit', + amount: 175.25, + currency: 'USD' + } + } + end + + run_test! + end + + response '201', 'withdrawal created' do + schema '$ref' => '#/components/schemas/TransactionResponse' + + let(:body) do + { + trade: { + account_id: account.id, + date: Date.current.to_s, + type: 'withdrawal', + amount: 100.00, + currency: 'USD' + } + } + end + + run_test! + end + + response '201', 'interest created' do + schema '$ref' => '#/components/schemas/TransactionResponse' + + let(:body) do + { + trade: { + account_id: account.id, + date: Date.current.to_s, + type: 'interest', + amount: 25.00, + currency: 'USD' + } + } + end + + run_test! + end + + response '422', 'deposit without amount returns error' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + trade: { + account_id: account.id, + date: Date.current.to_s, + type: 'deposit' + } + } + end + + run_test! + end end end @@ -329,7 +515,7 @@ RSpec.describe 'API V1 Trades', type: :request do date: { type: :string, format: :date }, qty: { type: :number }, price: { type: :number }, - type: { type: :string, enum: %w[buy sell] }, + type: { type: :string, enum: %w[buy sell dividend deposit withdrawal interest] }, nature: { type: :string, enum: %w[inflow outflow] }, name: { type: :string }, notes: { type: :string }, @@ -359,6 +545,33 @@ RSpec.describe 'API V1 Trades', type: :request do run_test! end + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { trade.id } + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'forbidden - api key missing read_write scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:read_only_api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Read Key', + key: key, + scopes: %w[read], + source: 'mobile' + ) + end + let(:'X-Api-Key') { read_only_api_key.plain_key } + + run_test! + end + response '404', 'trade not found' do schema '$ref' => '#/components/schemas/ErrorResponse' @@ -381,6 +594,33 @@ RSpec.describe 'API V1 Trades', type: :request do run_test! end + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { trade.id } + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'forbidden - api key missing read_write scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:read_only_api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Read Key', + key: key, + scopes: %w[read], + source: 'mobile' + ) + end + let(:'X-Api-Key') { read_only_api_key.plain_key } + + run_test! + end + response '404', 'trade not found' do schema '$ref' => '#/components/schemas/ErrorResponse' diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index b40dc7106..5d6a5cafb 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -935,6 +935,27 @@ RSpec.configure do |config| message: { type: :string } } }, + TransactionResponse: { + type: :object, + required: %w[id date amount currency name entryable_type account], + properties: { + id: { type: :string, format: :uuid }, + date: { type: :string, format: :date }, + amount: { type: :string }, + currency: { type: :string }, + name: { type: :string }, + entryable_type: { type: :string }, + account: { + type: :object, + required: %w[id name account_type], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string }, + account_type: { type: :string, nullable: true } + } + } + } + }, ImportConfiguration: { type: :object, properties: { diff --git a/test/controllers/api/v1/trades_controller_test.rb b/test/controllers/api/v1/trades_controller_test.rb new file mode 100644 index 000000000..89a27858f --- /dev/null +++ b/test/controllers/api/v1/trades_controller_test.rb @@ -0,0 +1,847 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::TradesControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @user.api_keys.active.destroy_all + @investment_account = accounts(:investment) + @read_write_api_key = nil + @read_only_api_key = nil + end + + test "create dividend with security returns 201" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "dividend", + date: Date.current, + amount: 25.50, + currency: "USD", + ticker: "AAPL|XNAS" + } }, + headers: api_headers(read_write_api_key) + + assert_response :created + body = JSON.parse(response.body) + assert body["id"].present? + assert_equal "Dividend: AAPL", body["name"] + assert_equal "Dividend", body["investment_activity_label"] + assert_equal 0, body["qty"].to_i + end + + test "create dividend without security returns 422" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "dividend", + date: Date.current, + amount: 25.50 + } }, + headers: api_headers(read_write_api_key) + + assert_response :unprocessable_entity + end + + test "create dividend without amount returns 422" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "dividend", + date: Date.current, + ticker: "AAPL|XNAS" + } }, + headers: api_headers(read_write_api_key) + + assert_response :unprocessable_entity + end + + test "create buy trade returns 201" do + security = Security.create!(ticker: "TEST", name: "Test Security", country_code: "US") + + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "buy", + date: Date.current, + qty: 10, + price: 100.00, + currency: "USD", + security_id: security.id + } }, + headers: api_headers(read_write_api_key) + + assert_response :created + body = JSON.parse(response.body) + assert body["id"].present? + assert_equal "Buy", body["investment_activity_label"] + end + + test "create sell trade returns 201" do + security = Security.create!(ticker: "TEST", name: "Test Security", country_code: "US") + + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "sell", + date: Date.current, + qty: 10, + price: 100.00, + currency: "USD", + security_id: security.id + } }, + headers: api_headers(read_write_api_key) + + assert_response :created + body = JSON.parse(response.body) + assert body["id"].present? + assert_equal "Sell", body["investment_activity_label"] + end + + test "invalid type returns 422" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "invalid", + date: Date.current + } }, + headers: api_headers(read_write_api_key) + + assert_response :unprocessable_entity + end + + test "create deposit returns 201" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "deposit", + date: Date.current, + amount: 175.25, + currency: "USD" + } }, + headers: api_headers(read_write_api_key) + + assert_response :created + body = JSON.parse(response.body) + assert body["id"].present? + assert_match(/Deposit to/, body["name"]) + end + + test "create withdrawal returns 201" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "withdrawal", + date: Date.current, + amount: 100.00, + currency: "USD" + } }, + headers: api_headers(read_write_api_key) + + assert_response :created + body = JSON.parse(response.body) + assert body["id"].present? + assert_match(/Withdrawal/, body["name"]) + end + + test "create withdrawal without amount returns 422" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "withdrawal", + date: Date.current + } }, + headers: api_headers(read_write_api_key) + + assert_response :unprocessable_entity + end + + test "create withdrawal with transfer_account_id creates linked transfer" do + depository = accounts(:depository) + + assert_difference "Transfer.count", 1 do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "withdrawal", + date: Date.current, + amount: 500.00, + currency: "USD", + transfer_account_id: depository.id + } }, + headers: api_headers(read_write_api_key) + end + + assert_response :created + body = JSON.parse(response.body) + assert body["id"].present? + assert body["outflow_transaction"]["account"]["id"].present? + assert body["outflow_transaction"]["account"]["account_type"].present? + + transfer = Transfer.joins(outflow_transaction: :entry) + .where(entries: { account_id: @investment_account.id }) + .last + + assert transfer, "Transfer should exist linking accounts" + assert_equal depository.id, transfer.inflow_transaction.entry.account_id, "Inflow should be to depository" + assert_equal @investment_account.id, transfer.outflow_transaction.entry.account_id, "Outflow should come from investment" + end + + test "create deposit without amount returns 422" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "deposit", + date: Date.current + } }, + headers: api_headers(read_write_api_key) + + assert_response :unprocessable_entity + end + + test "create deposit with transfer_account_id creates linked transfer" do + depository = accounts(:depository) + + assert_difference "Transfer.count", 1 do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "deposit", + date: Date.current, + amount: 500.00, + currency: "USD", + transfer_account_id: depository.id + } }, + headers: api_headers(read_write_api_key) + end + + assert_response :created + body = JSON.parse(response.body) + assert body["id"].present? + assert body["inflow_transaction"]["account"]["id"].present? + assert body["inflow_transaction"]["account"]["account_type"].present? + + transfer = Transfer.joins(inflow_transaction: :entry) + .where(entries: { account_id: @investment_account.id }) + .last + + assert transfer, "Transfer should exist linking accounts" + assert_equal depository.id, transfer.outflow_transaction.entry.account_id, "Outflow should be from depository" + assert_equal @investment_account.id, transfer.inflow_transaction.entry.account_id, "Inflow should go to investment" + end + + test "create interest returns 201" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "interest", + date: Date.current, + amount: 25.00, + currency: "USD" + } }, + headers: api_headers(read_write_api_key) + + assert_response :created + body = JSON.parse(response.body) + assert body["id"].present? + assert_equal "Interest", body["investment_activity_label"] + end + + test "create interest with explicit ticker returns 201" do + security = Security.create!(ticker: "INTSEC", name: "Interest Security", country_code: "US") + + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "interest", + date: Date.current, + amount: 25.00, + currency: "USD", + security_id: security.id + } }, + headers: api_headers(read_write_api_key) + + assert_response :created + body = JSON.parse(response.body) + assert body["id"].present? + assert_equal "Interest", body["investment_activity_label"] + end + + test "create interest without amount returns 422" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "interest", + date: Date.current + } }, + headers: api_headers(read_write_api_key) + + assert_response :unprocessable_entity + end + + test "create requires read_write scope" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "buy", + date: Date.current, + qty: 10, + price: 100 + } }, + headers: api_headers(read_only_api_key) + + assert_response :forbidden + end + + test "should reject create without API key" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "buy", + date: Date.current, + qty: 10, + price: 100 + } } + + assert_response :unauthorized + end + + test "should return 404 for unknown account_id" do + post "/api/v1/trades", + params: { trade: { + account_id: 999999, + type: "buy", + date: Date.current, + qty: 10, + price: 100, + security_id: Security.create!(ticker: "TEST", name: "Test", country_code: "US").id + } }, + headers: api_headers(read_write_api_key) + + assert_response :not_found + end + + test "should return 422 for invalid date format" do + security = Security.create!(ticker: "INVDATE", name: "Invalid Date Security", country_code: "US") + + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "buy", + date: "invalid-date", + qty: 10, + price: 100, + security_id: security.id + } }, + headers: api_headers(read_write_api_key) + + assert_response :unprocessable_entity + body = JSON.parse(response.body) + assert body["errors"].any? { |e| e.downcase.include?("date") }, "Expected date-related error, got: #{body["errors"].inspect}" + end + + # INDEX action tests + test "should get index with valid API key" do + get api_v1_trades_url, headers: api_headers(read_write_api_key) + assert_response :success + + response_data = JSON.parse(response.body) + assert response_data.key?("trades") + assert response_data.key?("pagination") + assert response_data["pagination"].key?("page") + assert response_data["pagination"].key?("per_page") + assert response_data["pagination"].key?("total_count") + assert response_data["pagination"].key?("total_pages") + end + + test "should get index with read-only API key" do + get api_v1_trades_url, headers: api_headers(read_only_api_key) + assert_response :success + end + + test "should filter trades by account_id" do + get api_v1_trades_url, + params: { account_id: @investment_account.id }, + headers: api_headers(read_write_api_key) + assert_response :success + + response_data = JSON.parse(response.body) + response_data["trades"].each do |trade| + assert_equal @investment_account.id, trade["account"]["id"] + end + end + + test "should filter trades by date range" do + start_date = 1.year.ago.to_date + end_date = Date.current + + get api_v1_trades_url, + params: { start_date: start_date, end_date: end_date }, + headers: api_headers(read_write_api_key) + assert_response :success + end + + test "should reject index request without API key" do + get api_v1_trades_url + assert_response :unauthorized + end + + test "should reject index request with invalid API key" do + get api_v1_trades_url, headers: { "X-Api-Key" => "invalid-key" } + assert_response :unauthorized + end + + test "should reject index request with invalid date format" do + get api_v1_trades_url, + params: { start_date: "invalid" }, + headers: api_headers(read_write_api_key) + assert_response :unprocessable_entity + end + + # SHOW action tests + test "should show trade with valid API key" do + security = Security.create!(ticker: "SHOW", name: "Show Security", country_code: "US") + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "buy", + date: Date.current, + qty: 5, + price: 100, + currency: "USD", + security_id: security.id + } }, + headers: api_headers(read_write_api_key) + assert_response :created + trade_id = JSON.parse(response.body)["id"] + + get api_v1_trade_url(trade_id), headers: api_headers(read_write_api_key) + assert_response :success + + response_data = JSON.parse(response.body) + assert_equal trade_id, response_data["id"] + assert response_data.key?("date") + assert response_data.key?("account") + end + + test "should show trade with read-only API key" do + security = Security.create!(ticker: "SHOR", name: "Show RO Security", country_code: "US") + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "buy", + date: Date.current, + qty: 5, + price: 100, + currency: "USD", + security_id: security.id + } }, + headers: api_headers(read_write_api_key) + assert_response :created + trade_id = JSON.parse(response.body)["id"] + + get api_v1_trade_url(trade_id), headers: api_headers(read_only_api_key) + assert_response :success + end + + test "should return 404 for non-existent trade" do + get api_v1_trade_url(999999), headers: api_headers(read_write_api_key) + assert_response :not_found + end + + test "should reject show request without API key" do + security = Security.create!(ticker: "SHON", name: "Show No Auth", country_code: "US") + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "buy", + date: Date.current, + qty: 5, + price: 100, + currency: "USD", + security_id: security.id + } }, + headers: api_headers(read_write_api_key) + assert_response :created + trade_id = JSON.parse(response.body)["id"] + + get api_v1_trade_url(trade_id) + assert_response :unauthorized + end + + # UPDATE action tests + test "should update trade with valid parameters" do + security = Security.create!(ticker: "UPD", name: "Update Security", country_code: "US") + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "buy", + date: Date.current, + qty: 5, + price: 100, + currency: "USD", + security_id: security.id + } }, + headers: api_headers(read_write_api_key) + assert_response :created + trade_id = JSON.parse(response.body)["id"] + + update_params = { + trade: { + notes: "Updated notes" + } + } + + put api_v1_trade_url(trade_id), + params: update_params, + headers: api_headers(read_write_api_key) + assert_response :success + + response_data = JSON.parse(response.body) + assert_equal "Updated notes", response_data["notes"] + end + + test "should reject update with read-only API key" do + security = Security.create!(ticker: "UPDRO", name: "Update RO Security", country_code: "US") + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "buy", + date: Date.current, + qty: 5, + price: 100, + currency: "USD", + security_id: security.id + } }, + headers: api_headers(read_write_api_key) + assert_response :created + trade_id = JSON.parse(response.body)["id"] + + put api_v1_trade_url(trade_id), + params: { trade: { notes: "Test" } }, + headers: api_headers(read_only_api_key) + assert_response :forbidden + end + + test "should reject update for non-existent trade" do + put api_v1_trade_url(999999), + params: { trade: { notes: "Test" } }, + headers: api_headers(read_write_api_key) + assert_response :not_found + end + + test "should reject update without API key" do + security = Security.create!(ticker: "UPDNO", name: "Update No Auth", country_code: "US") + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "buy", + date: Date.current, + qty: 5, + price: 100, + currency: "USD", + security_id: security.id + } }, + headers: api_headers(read_write_api_key) + assert_response :created + trade_id = JSON.parse(response.body)["id"] + + put api_v1_trade_url(trade_id), params: { trade: { notes: "Test" } } + assert_response :unauthorized + end + + test "should reject update with invalid date format" do + security = Security.create!(ticker: "UPDID", name: "Update Invalid Date", country_code: "US") + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "buy", + date: Date.current, + qty: 5, + price: 100, + currency: "USD", + security_id: security.id + } }, + headers: api_headers(read_write_api_key) + assert_response :created + trade_id = JSON.parse(response.body)["id"] + + put api_v1_trade_url(trade_id), + params: { trade: { date: "invalid" } }, + headers: api_headers(read_write_api_key) + assert_response :unprocessable_entity + end + + test "should update dividend trade with valid parameters" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "dividend", + date: Date.current, + amount: 25.50, + currency: "USD", + ticker: "AAPL|XNAS" + } }, + headers: api_headers(read_write_api_key) + assert_response :created + trade_id = JSON.parse(response.body)["id"] + + update_params = { + trade: { + notes: "Updated dividend notes" + } + } + + put api_v1_trade_url(trade_id), + params: update_params, + headers: api_headers(read_write_api_key) + assert_response :success + + response_data = JSON.parse(response.body) + assert_equal "Updated dividend notes", response_data["notes"] + end + + # DESTROY action tests + test "should destroy trade" do + security = Security.create!(ticker: "DEL", name: "Delete Security", country_code: "US") + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "buy", + date: Date.current, + qty: 5, + price: 100, + currency: "USD", + security_id: security.id + } }, + headers: api_headers(read_write_api_key) + assert_response :created + trade_id = JSON.parse(response.body)["id"] + + delete api_v1_trade_url(trade_id), headers: api_headers(read_write_api_key) + assert_response :success + + response_data = JSON.parse(response.body) + assert response_data.key?("message") + end + + test "should reject destroy with read-only API key" do + security = Security.create!(ticker: "DELRO", name: "Delete RO Security", country_code: "US") + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "buy", + date: Date.current, + qty: 5, + price: 100, + currency: "USD", + security_id: security.id + } }, + headers: api_headers(read_write_api_key) + assert_response :created + trade_id = JSON.parse(response.body)["id"] + + delete api_v1_trade_url(trade_id), headers: api_headers(read_only_api_key) + assert_response :forbidden + end + + test "should reject destroy for non-existent trade" do + delete api_v1_trade_url(999999), headers: api_headers(read_write_api_key) + assert_response :not_found + end + + test "should reject destroy without API key" do + security = Security.create!(ticker: "DELNO", name: "Delete No Auth", country_code: "US") + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "buy", + date: Date.current, + qty: 5, + price: 100, + currency: "USD", + security_id: security.id + } }, + headers: api_headers(read_write_api_key) + assert_response :created + trade_id = JSON.parse(response.body)["id"] + + delete api_v1_trade_url(trade_id) + assert_response :unauthorized + end + + # Non-numeric amount returns 422 + test "create deposit with non-numeric amount returns 422" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "deposit", + date: Date.current, + amount: "abc", + currency: "USD" + } }, + headers: api_headers(read_write_api_key) + + assert_response :unprocessable_entity + body = JSON.parse(response.body) + assert_equal "Amount must be a valid number", body["message"] + end + + test "create deposit with zero amount returns 422" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "deposit", + date: Date.current, + amount: 0, + currency: "USD" + } }, + headers: api_headers(read_write_api_key) + + assert_response :unprocessable_entity + body = JSON.parse(response.body) + assert_equal "Amount must be a valid number", body["message"] + end + + test "create deposit with negative amount returns 422" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "deposit", + date: Date.current, + amount: -50.00, + currency: "USD" + } }, + headers: api_headers(read_write_api_key) + + assert_response :unprocessable_entity + body = JSON.parse(response.body) + assert_equal "Amount must be a valid number", body["message"] + end + + test "create interest with non-numeric amount returns 422" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "interest", + date: Date.current, + amount: "abc", + currency: "USD" + } }, + headers: api_headers(read_write_api_key) + + assert_response :unprocessable_entity + body = JSON.parse(response.body) + assert_equal "Amount must be a valid number", body["message"] + end + + test "create interest with zero amount returns 422" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "interest", + date: Date.current, + amount: 0, + currency: "USD" + } }, + headers: api_headers(read_write_api_key) + + assert_response :unprocessable_entity + body = JSON.parse(response.body) + assert_equal "Amount must be a valid number", body["message"] + end + + test "create dividend with non-numeric amount returns 422" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "dividend", + date: Date.current, + amount: "abc", + currency: "USD", + ticker: "AAPL|XNAS" + } }, + headers: api_headers(read_write_api_key) + + assert_response :unprocessable_entity + body = JSON.parse(response.body) + assert_equal "Amount must be a valid number", body["message"] + end + + test "create dividend with zero amount returns 422" do + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "dividend", + date: Date.current, + amount: 0, + currency: "USD", + ticker: "AAPL|XNAS" + } }, + headers: api_headers(read_write_api_key) + + assert_response :unprocessable_entity + body = JSON.parse(response.body) + assert_equal "Amount must be a valid number", body["message"] + end + + test "trade JSON should have expected structure" do + security = Security.create!(ticker: "JSP", name: "JSON Structure Security", country_code: "US") + post "/api/v1/trades", + params: { trade: { + account_id: @investment_account.id, + type: "buy", + date: Date.current, + qty: 5, + price: 100, + currency: "USD", + security_id: security.id + } }, + headers: api_headers(read_write_api_key) + assert_response :created + trade_id = JSON.parse(response.body)["id"] + + get api_v1_trade_url(trade_id), headers: api_headers(read_write_api_key) + assert_response :success + + trade_data = JSON.parse(response.body) + assert trade_data.key?("id") + assert trade_data.key?("date") + assert trade_data.key?("account") + assert trade_data["account"].key?("id") + assert trade_data["account"].key?("name") + assert trade_data["account"].key?("account_type") + assert trade_data.key?("notes") + end + + private + + def read_write_api_key + @read_write_api_key ||= ApiKey.create!( + user: @user, + name: "Test RW Key", + key: ApiKey.generate_secure_key, + scopes: %w[read_write], + source: "web" + ).tap do |key| + Redis.new.del("api_rate_limit:#{key.id}") + end + end + + def read_only_api_key + @read_only_api_key ||= ApiKey.create!( + user: @user, + name: "Test RO Key", + key: ApiKey.generate_secure_key, + scopes: %w[read], + source: "mobile" + ).tap do |key| + Redis.new.del("api_rate_limit:#{key.id}") + end + end + + def api_headers(api_key) + { "X-Api-Key" => api_key.plain_key } + end +end