diff --git a/app/controllers/api/v1/holdings_controller.rb b/app/controllers/api/v1/holdings_controller.rb new file mode 100644 index 000000000..58d094abd --- /dev/null +++ b/app/controllers/api/v1/holdings_controller.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +class Api::V1::HoldingsController < Api::V1::BaseController + include Pagy::Backend + + before_action :ensure_read_scope + before_action :set_holding, only: [ :show ] + + def index + family = current_resource_owner.family + holdings_query = family.holdings.joins(:account).where(accounts: { status: [ "draft", "active" ] }) + + holdings_query = apply_filters(holdings_query) + holdings_query = holdings_query.includes(:account, :security).chronological + + @pagy, @holdings = pagy( + holdings_query, + page: safe_page_param, + limit: safe_per_page_param + ) + @per_page = safe_per_page_param + + render :index + rescue ArgumentError => e + render_validation_error(e.message, [ e.message ]) + rescue => e + log_and_render_error("index", e) + end + + def show + render :show + rescue => e + log_and_render_error("show", e) + end + + private + + def set_holding + family = current_resource_owner.family + @holding = family.holdings.joins(:account).where(accounts: { status: %w[draft active] }).find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: "not_found", message: "Holding not found" }, status: :not_found + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def apply_filters(query) + if params[:account_id].present? + query = query.where(account_id: params[:account_id]) + end + if params[:account_ids].present? + query = query.where(account_id: Array(params[:account_ids])) + end + if params[:date].present? + query = query.where(date: parse_date!(params[:date], "date")) + end + if params[:start_date].present? + query = query.where("holdings.date >= ?", parse_date!(params[:start_date], "start_date")) + end + if params[:end_date].present? + query = query.where("holdings.date <= ?", parse_date!(params[:end_date], "end_date")) + end + if params[:security_id].present? + query = query.where(security_id: params[:security_id]) + end + query + 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 + + def parse_date!(value, param_name) + Date.parse(value) + rescue Date::Error, ArgumentError, TypeError + raise ArgumentError, "Invalid #{param_name} format" + end + + def render_validation_error(message, errors) + render json: { + error: "validation_failed", + message: message, + errors: errors + }, status: :unprocessable_entity + end + + def log_and_render_error(action, exception) + Rails.logger.error "HoldingsController##{action} error: #{exception.message}" + Rails.logger.error exception.backtrace.join("\n") + render json: { + error: "internal_server_error", + message: "Error: #{exception.message}" + }, status: :internal_server_error + end +end diff --git a/app/controllers/api/v1/trades_controller.rb b/app/controllers/api/v1/trades_controller.rb new file mode 100644 index 000000000..8f442c81a --- /dev/null +++ b/app/controllers/api/v1/trades_controller.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +class Api::V1::TradesController < Api::V1::BaseController + include Pagy::Backend + + before_action :ensure_read_scope, only: [ :index, :show ] + before_action :ensure_write_scope, only: [ :create, :update, :destroy ] + before_action :set_trade, only: [ :show, :update, :destroy ] + + def index + family = current_resource_owner.family + trades_query = family.trades.visible + + trades_query = apply_filters(trades_query) + trades_query = trades_query.includes({ entry: :account }, :security, :category).reverse_chronological + + @pagy, @trades = pagy( + trades_query, + page: safe_page_param, + limit: safe_per_page_param + ) + @per_page = safe_per_page_param + + render :index + rescue ArgumentError => e + render_validation_error(e.message, [ e.message ]) + rescue => e + log_and_render_error("index", e) + end + + def show + render :show + rescue => e + log_and_render_error("show", e) + end + + def create + unless trade_params[:account_id].present? + return render_validation_error("Account ID is required", [ "Account ID is required" ]) + end + + account = current_resource_owner.family.accounts.visible.find(trade_params[:account_id]) + + unless account.supports_trades? + return render_validation_error( + "Account does not support trades (investment or crypto exchange only)", + [ "Account must be an investment or crypto exchange account" ] + ) + end + + create_params = build_create_form_params(account) + return if performed? # build_create_form_params may have rendered validation errors + + model = Trade::CreateForm.new(create_params).create + + unless model.persisted? + errors = model.is_a?(Entry) ? model.errors.full_messages : [ "Trade could not be created" ] + return render_validation_error("Trade could not be created", errors) + end + + if model.is_a?(Entry) + model.lock_saved_attributes! + model.mark_user_modified! + model.sync_account_later + @trade = model.trade + else + @trade = model + 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 + rescue => e + log_and_render_error("create", e) + end + + def update + updatable = build_entry_params_for_update + + if @entry.update(updatable.except(:nature)) + @entry.lock_saved_attributes! + @entry.mark_user_modified! + @entry.sync_account_later + @trade = @entry.trade + render :show + else + render_validation_error("Trade could not be updated", @entry.errors.full_messages) + end + rescue => e + log_and_render_error("update", e) + end + + def destroy + @entry = @trade.entry + @entry.destroy! + @entry.sync_account_later + + render json: { message: "Trade deleted successfully" }, status: :ok + rescue => e + log_and_render_error("destroy", e) + end + + private + + def set_trade + family = current_resource_owner.family + @trade = family.trades.visible.find(params[:id]) + @entry = @trade.entry + rescue ActiveRecord::RecordNotFound + render json: { error: "not_found", message: "Trade not found" }, status: :not_found + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def ensure_write_scope + authorize_scope!(:write) + end + + def apply_filters(query) + need_entry_join = params[:account_id].present? || params[:account_ids].present? || + params[:start_date].present? || params[:end_date].present? + query = query.joins(:entry) if need_entry_join + + if params[:account_id].present? + query = query.where(entries: { account_id: params[:account_id] }) + end + if params[:account_ids].present? + query = query.where(entries: { account_id: Array(params[:account_ids]) }) + end + if params[:start_date].present? + query = query.where("entries.date >= ?", parse_date!(params[:start_date], "start_date")) + end + if params[:end_date].present? + query = query.where("entries.date <= ?", parse_date!(params[:end_date], "end_date")) + end + query + end + + def trade_params + params.require(:trade).permit( + :account_id, :date, :qty, :price, :currency, + :security_id, :ticker, :manual_ticker, :investment_activity_label, :category_id + ) + end + + def trade_update_params + params.require(:trade).permit( + :name, :date, :amount, :currency, :notes, :nature, :type, + :qty, :price, :investment_activity_label, :category_id + ) + end + + def build_entry_params_for_update + flat = trade_update_params.to_h + entry_params = { + name: flat[:name], + date: flat[:date], + amount: flat[:amount], + currency: flat[:currency], + notes: flat[:notes], + entryable_type: "Trade", + entryable_attributes: { + id: @trade.id, + investment_activity_label: flat[:investment_activity_label], + category_id: flat[:category_id] + }.compact_blank + }.compact + + original_qty = flat[:qty] + original_price = flat[:price] + type_or_nature = flat[:type].presence || flat[:nature] + + if original_qty.present? || original_price.present? + qty = original_qty.present? ? original_qty : @trade.qty.abs + price = original_price.present? ? original_price : @trade.price + is_sell = type_or_nature.present? ? trade_sell_from_type_or_nature?(type_or_nature) : @trade.qty.negative? + signed_qty = is_sell ? -qty.to_d.abs : qty.to_d.abs + entry_params[:entryable_attributes][:qty] = signed_qty + entry_params[:amount] = signed_qty * price.to_d + ticker = @trade.security&.ticker + entry_params[:name] = Trade.build_name(is_sell ? "sell" : "buy", signed_qty.abs, ticker) if ticker.present? + entry_params[:entryable_attributes][:investment_activity_label] = flat[:investment_activity_label].presence || @trade.investment_activity_label.presence || (is_sell ? "Sell" : "Buy") + end + + entry_params + end + + # True for sell: "sell" or "inflow". False for buy: "buy", "outflow", or blank. Keeps create (buy/sell) and update (type or nature) consistent. + def trade_sell_from_type_or_nature?(value) + return false if value.blank? + + normalized = value.to_s.downcase.strip + %w[sell inflow].include?(normalized) + end + + 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'" ]) + return nil + end + + 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 + + qty_raw = trade_params[:qty].to_s.strip + price_raw = trade_params[:price].to_s.strip + return render_validation_error("Quantity and price are required", [ "qty and price must be present and positive" ]) if qty_raw.blank? || price_raw.blank? + + qty = qty_raw.to_d + price = price_raw.to_d + if qty <= 0 || price <= 0 + # Non-numeric input (e.g. "abc") becomes 0 with to_d; give a clearer message than "must be present" + non_numeric = (qty.zero? && qty_raw !~ /\A0(\.0*)?\z/) || (price.zero? && price_raw !~ /\A0(\.0*)?\z/) + return render_validation_error("Quantity and price must be valid numbers", [ "qty and price must be valid positive numbers" ]) if non_numeric + return render_validation_error("Quantity and price are required", [ "qty and price must be present and positive" ]) + end + + { + account: account, + date: trade_params[:date], + qty: qty, + price: price, + currency: trade_params[:currency].presence || account.currency, + type: type, + ticker: ticker_value, + manual_ticker: manual_ticker_value + }.compact + end + + def apply_trade_create_options! + attrs = {} + if trade_params[:investment_activity_label].present? + label = trade_params[:investment_activity_label] + unless Trade::ACTIVITY_LABELS.include?(label) + render_validation_error("Invalid investment_activity_label", [ "investment_activity_label must be one of: #{Trade::ACTIVITY_LABELS.join(', ')}" ]) + return + end + attrs[:investment_activity_label] = label + end + if trade_params[:category_id].present? + category = current_resource_owner.family.categories.find_by(id: trade_params[:category_id]) + unless category + render_validation_error("Category not found or does not belong to your family", [ "category_id is invalid" ]) + return + end + attrs[:category_id] = category.id + end + @trade.update!(attrs) if attrs.any? + end + + def render_validation_error(message, errors) + render json: { + error: "validation_failed", + message: message, + errors: errors + }, status: :unprocessable_entity + end + + def parse_date!(value, param_name) + Date.parse(value) + rescue Date::Error, ArgumentError, TypeError + raise ArgumentError, "Invalid #{param_name} format" + end + + def log_and_render_error(action, exception) + Rails.logger.error "TradesController##{action} error: #{exception.message}" + Rails.logger.error exception.backtrace.join("\n") + render json: { + error: "internal_server_error", + message: "Error: #{exception.message}" + }, status: :internal_server_error + end + + def safe_page_param + page = params[:page].to_i + page > 0 ? page : 1 + end + + def safe_per_page_param + per_page = params[:per_page].to_i + case per_page + when 1..100 + per_page + else + 25 + end + end +end diff --git a/app/views/api/v1/holdings/_holding.json.jbuilder b/app/views/api/v1/holdings/_holding.json.jbuilder new file mode 100644 index 000000000..27cfb4f29 --- /dev/null +++ b/app/views/api/v1/holdings/_holding.json.jbuilder @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +json.id holding.id +json.date holding.date +json.qty holding.qty +json.price Money.new(holding.price, holding.currency).format +json.amount holding.amount_money.format +json.currency holding.currency +json.cost_basis_source holding.cost_basis_source + +json.account do + json.id holding.account.id + json.name holding.account.name + json.account_type holding.account.accountable_type.underscore +end + +json.security do + json.id holding.security.id + json.ticker holding.security.ticker + json.name holding.security.name +end + +avg = holding.avg_cost +json.avg_cost avg ? avg.format : nil + +json.created_at holding.created_at.iso8601 +json.updated_at holding.updated_at.iso8601 diff --git a/app/views/api/v1/holdings/index.json.jbuilder b/app/views/api/v1/holdings/index.json.jbuilder new file mode 100644 index 000000000..589fe80b1 --- /dev/null +++ b/app/views/api/v1/holdings/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.holdings @holdings do |holding| + json.partial! "holding", holding: holding +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/holdings/show.json.jbuilder b/app/views/api/v1/holdings/show.json.jbuilder new file mode 100644 index 000000000..2991363c1 --- /dev/null +++ b/app/views/api/v1/holdings/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "holding", holding: @holding diff --git a/app/views/api/v1/trades/_trade.json.jbuilder b/app/views/api/v1/trades/_trade.json.jbuilder new file mode 100644 index 000000000..29b3acb3a --- /dev/null +++ b/app/views/api/v1/trades/_trade.json.jbuilder @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +json.id trade.id +json.date trade.entry.date +json.amount trade.entry.amount_money.format +json.currency trade.currency +json.name trade.entry.name +json.notes trade.entry.notes +json.qty trade.qty +json.price trade.price_money.format +json.investment_activity_label trade.investment_activity_label + +json.account do + json.id trade.entry.account.id + json.name trade.entry.account.name + json.account_type trade.entry.account.accountable_type.underscore +end + +if trade.security.present? + json.security do + json.id trade.security.id + json.ticker trade.security.ticker + json.name trade.security.name + end +else + json.security nil +end + +if trade.category.present? + json.category do + json.id trade.category.id + json.name trade.category.name + end +else + json.category nil +end + +json.created_at trade.created_at.iso8601 +json.updated_at trade.updated_at.iso8601 diff --git a/app/views/api/v1/trades/index.json.jbuilder b/app/views/api/v1/trades/index.json.jbuilder new file mode 100644 index 000000000..915532bf0 --- /dev/null +++ b/app/views/api/v1/trades/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.trades @trades do |trade| + json.partial! "trade", trade: trade +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/trades/show.json.jbuilder b/app/views/api/v1/trades/show.json.jbuilder new file mode 100644 index 000000000..08f005b8f --- /dev/null +++ b/app/views/api/v1/trades/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "trade", trade: @trade diff --git a/config/brakeman.ignore b/config/brakeman.ignore index a03fb993b..ca044eb6c 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -69,6 +69,29 @@ ], "note": "account_id is properly validated in create action - line 79 ensures account belongs to user's family: family.accounts.find(transaction_params[:account_id])" }, + { + "warning_type": "Mass Assignment", + "warning_code": 105, + "fingerprint": "d770e95392c6c69b364dcc0c99faa1c8f4f0cceb085bcc55630213d0b7b8b87f", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/api/v1/trades_controller.rb", + "line": 165, + "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)", + "render_path": null, + "location": { + "type": "method", + "class": "Api::V1::TradesController", + "method": "trade_params" + }, + "user_input": ":account_id", + "confidence": "High", + "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" + }, { "warning_type": "Mass Assignment", "warning_code": 105, diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 6a3eb3baf..4a16a69bb 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true class Rack::Attack - # Enable Rack::Attack - self.enabled = Rails.env.production? || Rails.env.staging? + # Enable Rack::Attack only in production and staging (disable in test/development to avoid rate-limit flakiness) + enabled = Rails.env.production? || Rails.env.staging? + self.enabled = enabled # Throttle requests to the OAuth token endpoint throttle("oauth/token", limit: 10, period: 1.minute) do |request| diff --git a/config/routes.rb b/config/routes.rb index d5363cb35..1d10471e4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -365,6 +365,8 @@ Rails.application.routes.draw do resources :tags, only: %i[index show create update destroy] resources :transactions, only: [ :index, :show, :create, :update, :destroy ] + resources :trades, only: [ :index, :show, :create, :update, :destroy ] + resources :holdings, only: [ :index, :show ] resources :valuations, only: [ :create, :update, :show ] resources :imports, only: [ :index, :show, :create ] resource :usage, only: [ :show ], controller: :usage diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1123305ad..9417f1e97 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -54,6 +54,13 @@ components: type: string - type: object nullable: true + errors: + type: array + items: + type: string + nullable: true + description: Validation error messages (alternative to details used by trades, + valuations, etc.) ToolCall: type: object required: @@ -486,7 +493,6 @@ components: id: type: string format: uuid - description: Entry ID for the valuation date: type: string format: date @@ -693,6 +699,154 @@ components: properties: data: "$ref": "#/components/schemas/ImportDetail" + Trade: + type: object + required: + - id + - date + - amount + - currency + - name + - qty + - price + - account + - created_at + - updated_at + properties: + id: + type: string + format: uuid + date: + type: string + format: date + amount: + type: string + currency: + type: string + name: + type: string + notes: + type: string + nullable: true + qty: + type: string + price: + type: string + investment_activity_label: + type: string + nullable: true + account: + "$ref": "#/components/schemas/Account" + security: + type: object + nullable: true + properties: + id: + type: string + format: uuid + ticker: + type: string + name: + type: string + nullable: true + category: + type: object + nullable: true + properties: + id: + type: string + format: uuid + name: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + TradeCollection: + type: object + required: + - trades + - pagination + properties: + trades: + type: array + items: + "$ref": "#/components/schemas/Trade" + pagination: + "$ref": "#/components/schemas/Pagination" + Holding: + type: object + required: + - id + - date + - qty + - price + - amount + - currency + - account + - security + - created_at + - updated_at + properties: + id: + type: string + format: uuid + date: + type: string + format: date + qty: + type: string + description: Quantity as string (JSON number or string from API) + price: + type: string + description: Price as string (JSON number or string from API) + amount: + type: string + currency: + type: string + cost_basis_source: + type: string + nullable: true + account: + "$ref": "#/components/schemas/Account" + security: + type: object + required: + - id + - ticker + - name + properties: + id: + type: string + format: uuid + ticker: + type: string + name: + type: string + nullable: true + avg_cost: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + HoldingCollection: + type: object + required: + - holdings + - pagination + properties: + holdings: + type: array + items: + "$ref": "#/components/schemas/Holding" + pagination: + "$ref": "#/components/schemas/Pagination" paths: "/api/v1/accounts": get: @@ -1009,6 +1163,119 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/holdings": + get: + summary: List holdings + tags: + - Holdings + 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 + - name: account_ids + in: query + required: false + description: Filter by multiple account IDs + schema: + type: array + items: + type: string + - name: date + in: query + required: false + description: Filter by exact date + schema: + type: string + format: date + - name: start_date + in: query + required: false + description: Filter holdings from this date (inclusive) + schema: + type: string + format: date + - name: end_date + in: query + required: false + description: Filter holdings until this date (inclusive) + schema: + type: string + format: date + - name: security_id + in: query + required: false + description: Filter by security ID + schema: + type: string + responses: + '200': + description: holdings paginated + content: + application/json: + schema: + "$ref": "#/components/schemas/HoldingCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: invalid date filter + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/holdings/{id}": + parameters: + - name: id + in: path + description: Holding ID + required: true + schema: + type: string + get: + summary: Retrieve holding + tags: + - Holdings + security: + - apiKeyAuth: [] + responses: + '200': + description: holding retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/Holding" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: holding not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/imports": get: summary: List imports @@ -1309,6 +1576,277 @@ paths: description: tag deleted '404': description: tag not found + "/api/v1/trades": + get: + summary: List trades + tags: + - Trades + 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 + - name: account_ids + in: query + required: false + description: Filter by multiple account IDs + schema: + type: array + items: + type: string + - name: start_date + in: query + required: false + description: Filter trades from this date (inclusive) + schema: + type: string + format: date + - name: end_date + in: query + required: false + description: Filter trades until this date (inclusive) + schema: + type: string + format: date + responses: + '200': + description: trades paginated + content: + application/json: + schema: + "$ref": "#/components/schemas/TradeCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: invalid date filter + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + post: + summary: Create trade + tags: + - Trades + security: + - apiKeyAuth: [] + parameters: [] + responses: + '201': + description: trade created + content: + application/json: + schema: + "$ref": "#/components/schemas/Trade" + '422': + description: validation error - missing security identifier + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: account not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + trade: + type: object + 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: + - buy + - sell + 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 + required: + - account_id + - date + - qty + - price + - type + required: + - trade + required: true + "/api/v1/trades/{id}": + parameters: + - name: id + in: path + description: Trade ID + required: true + schema: + type: string + get: + summary: Retrieve trade + tags: + - Trades + security: + - apiKeyAuth: [] + responses: + '200': + description: trade retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/Trade" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: trade not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + patch: + summary: Update trade + tags: + - Trades + security: + - apiKeyAuth: [] + responses: + '200': + description: trade updated + content: + application/json: + schema: + "$ref": "#/components/schemas/Trade" + '404': + description: trade not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: validation error + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + trade: + type: object + description: Flat params; controller builds internal structure. When qty/price are updated, type or nature controls sign; if omitted, existing trade direction is preserved. + properties: + date: + type: string + format: date + name: + type: string + amount: + type: number + currency: + type: string + notes: + type: string + nature: + type: string + enum: + - inflow + - outflow + type: + type: string + enum: + - buy + - sell + description: Determines sign when qty/price are updated. + qty: + type: number + price: + type: number + investment_activity_label: + type: string + category_id: + type: string + format: uuid + required: true + delete: + summary: Delete trade + tags: + - Trades + security: + - apiKeyAuth: [] + responses: + '200': + description: trade deleted + content: + application/json: + schema: + "$ref": "#/components/schemas/DeleteResponse" + '404': + description: trade not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/transactions": get: summary: List transactions diff --git a/spec/requests/api/v1/holdings_spec.rb b/spec/requests/api/v1/holdings_spec.rb new file mode 100644 index 000000000..27d7ac4f3 --- /dev/null +++ b/spec/requests/api/v1/holdings_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Holdings', 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(:'X-Api-Key') { api_key.plain_key } + + let(:account) do + Account.create!( + family: family, + name: 'Investment Account', + balance: 50_000, + currency: 'USD', + accountable: Investment.create! + ) + end + + let(:security) do + Security.create!( + ticker: 'VTI', + name: 'Vanguard Total Stock Market ETF', + country_code: 'US' + ) + end + + let!(:holding) do + Holding.create!( + account: account, + security: security, + date: Date.current, + qty: 100, + price: 250.50, + amount: 25_050, + currency: 'USD' + ) + end + + path '/api/v1/holdings' do + get 'List holdings' do + tags 'Holdings' + 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, type: :string, required: false, + description: 'Filter by account ID' + parameter name: :account_ids, in: :query, required: false, + description: 'Filter by multiple account IDs', + schema: { type: :array, items: { type: :string } } + parameter name: :date, in: :query, required: false, + description: 'Filter by exact date', + schema: { type: :string, format: :date } + parameter name: :start_date, in: :query, required: false, + description: 'Filter holdings from this date (inclusive)', + schema: { type: :string, format: :date } + parameter name: :end_date, in: :query, required: false, + description: 'Filter holdings until this date (inclusive)', + schema: { type: :string, format: :date } + parameter name: :security_id, in: :query, type: :string, required: false, + description: 'Filter by security ID' + + response '200', 'holdings listed' do + schema '$ref' => '#/components/schemas/HoldingCollection' + + run_test! + end + + response '200', 'holdings filtered by account' do + schema '$ref' => '#/components/schemas/HoldingCollection' + + let(:account_id) { account.id } + + run_test! + end + + response '200', 'holdings filtered by date range' do + schema '$ref' => '#/components/schemas/HoldingCollection' + + let(:start_date) { (Date.current - 7.days).to_s } + let(:end_date) { Date.current.to_s } + + run_test! + end + + response '200', 'holdings filtered by security' do + schema '$ref' => '#/components/schemas/HoldingCollection' + + let(:security_id) { security.id } + + run_test! + end + + response '200', 'holdings paginated' do + schema '$ref' => '#/components/schemas/HoldingCollection' + + let(:page) { 1 } + let(:per_page) { 10 } + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + 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/holdings/{id}' do + parameter name: :id, in: :path, type: :string, required: true, description: 'Holding ID' + + get 'Retrieve holding' do + tags 'Holdings' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '200', 'holding retrieved' do + schema '$ref' => '#/components/schemas/Holding' + + let(:id) { holding.id } + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { holding.id } + let(:'X-Api-Key') { nil } + + run_test! + end + + response '404', 'holding not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/trades_spec.rb b/spec/requests/api/v1/trades_spec.rb new file mode 100644 index 000000000..1eac1032d --- /dev/null +++ b/spec/requests/api/v1/trades_spec.rb @@ -0,0 +1,394 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Trades', 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(:'X-Api-Key') { api_key.plain_key } + + let(:account) do + Account.create!( + family: family, + name: 'Investment Account', + balance: 50_000, + currency: 'USD', + accountable: Investment.create! + ) + end + + let(:security) do + Security.create!( + ticker: 'VTI', + name: 'Vanguard Total Stock Market ETF', + country_code: 'US' + ) + end + + let(:category) do + family.categories.create!( + name: 'Investments', + classification: 'expense', + color: '#2196F3', + lucide_icon: 'trending-up' + ) + end + + let!(:trade) do + trade_record = Trade.new( + security: security, + qty: 100, + price: 250.50, + currency: 'USD', + investment_activity_label: 'Buy' + ) + entry = account.entries.create!( + name: 'Buy 100 shares of VTI', + date: Date.current, + amount: 25_050, + currency: 'USD', + entryable: trade_record + ) + entry.entryable + end + + path '/api/v1/trades' do + get 'List trades' do + tags 'Trades' + 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, type: :string, required: false, + description: 'Filter by account ID' + parameter name: :account_ids, in: :query, required: false, + description: 'Filter by multiple account IDs', + schema: { type: :array, items: { type: :string } } + parameter name: :start_date, in: :query, required: false, + description: 'Filter trades from this date (inclusive)', + schema: { type: :string, format: :date } + parameter name: :end_date, in: :query, required: false, + description: 'Filter trades until this date (inclusive)', + schema: { type: :string, format: :date } + + response '200', 'trades listed' do + schema '$ref' => '#/components/schemas/TradeCollection' + + run_test! + end + + response '200', 'trades filtered by account' do + schema '$ref' => '#/components/schemas/TradeCollection' + + let(:account_id) { account.id } + + run_test! + end + + response '200', 'trades filtered by date range' do + schema '$ref' => '#/components/schemas/TradeCollection' + + let(:start_date) { (Date.current - 7.days).to_s } + let(:end_date) { Date.current.to_s } + + run_test! + end + + response '200', 'trades paginated' do + schema '$ref' => '#/components/schemas/TradeCollection' + + let(:page) { 1 } + let(:per_page) { 10 } + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '422', 'invalid date filter' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:start_date) { 'not-a-date' } + + run_test! + end + end + + post 'Create trade' do + tags 'Trades' + security [ { apiKeyAuth: [] } ] + consumes 'application/json' + produces 'application/json' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + trade: { + type: :object, + 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)' }, + 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' } + }, + required: %w[account_id date qty price type] + } + }, + required: %w[trade] + } + + let(:body) do + { + trade: { + account_id: account.id, + date: Date.current.to_s, + qty: 50, + price: 100.00, + type: 'buy', + security_id: security.id + } + } + end + + response '201', 'trade created' do + schema '$ref' => '#/components/schemas/Trade' + + run_test! + end + + response '422', 'account does not support trades' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:checking_account) do + Account.create!( + family: family, + name: 'Checking', + balance: 1000, + currency: 'USD', + accountable: Depository.create! + ) + end + let(:body) do + { + trade: { + account_id: checking_account.id, + date: Date.current.to_s, + qty: 10, + price: 50, + type: 'buy', + security_id: security.id + } + } + end + + run_test! + end + + response '404', 'account not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + trade: { + account_id: SecureRandom.uuid, + date: Date.current.to_s, + qty: 10, + price: 50, + type: 'buy', + security_id: security.id + } + } + end + + run_test! + end + + response '422', 'validation error - missing type' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + trade: { + account_id: account.id, + date: Date.current.to_s, + qty: 10, + price: 50, + security_id: security.id + } + } + end + + run_test! + end + + response '422', 'validation error - missing security identifier' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + trade: { + account_id: account.id, + date: Date.current.to_s, + qty: 10, + price: 50, + type: 'buy' + } + } + end + + run_test! + end + end + end + + path '/api/v1/trades/{id}' do + parameter name: :id, in: :path, type: :string, required: true, description: 'Trade ID' + + get 'Retrieve trade' do + tags 'Trades' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '200', 'trade retrieved' do + schema '$ref' => '#/components/schemas/Trade' + + let(:id) { trade.id } + + 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 '404', 'trade not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + + patch 'Update trade' do + tags 'Trades' + security [ { apiKeyAuth: [] } ] + consumes 'application/json' + produces 'application/json' + + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + trade: { + type: :object, + properties: { + date: { type: :string, format: :date }, + qty: { type: :number }, + price: { type: :number }, + type: { type: :string, enum: %w[buy sell] }, + nature: { type: :string, enum: %w[inflow outflow] }, + name: { type: :string }, + notes: { type: :string }, + currency: { type: :string }, + investment_activity_label: { type: :string }, + category_id: { type: :string, format: :uuid } + } + } + } + } + + let(:body) do + { + trade: { + qty: 75, + price: 255.00, + type: 'buy' + } + } + end + + response '200', 'trade updated' do + schema '$ref' => '#/components/schemas/Trade' + + let(:id) { trade.id } + + run_test! + end + + response '404', 'trade not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + + delete 'Delete trade' do + tags 'Trades' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '200', 'trade deleted' do + schema '$ref' => '#/components/schemas/DeleteResponse' + + let(:id) { trade.id } + + run_test! + end + + response '404', 'trade not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/valuations_spec.rb b/spec/requests/api/v1/valuations_spec.rb index 9250c14f8..04319c688 100644 --- a/spec/requests/api/v1/valuations_spec.rb +++ b/spec/requests/api/v1/valuations_spec.rb @@ -103,14 +103,7 @@ RSpec.describe 'API V1 Valuations', type: :request do response '201', 'valuation created' do schema '$ref' => '#/components/schemas/Valuation' - run_test! do |response| - data = JSON.parse(response.body) - created_id = data.fetch('id') - get "/api/v1/valuations/#{created_id}", headers: { 'Authorization' => Authorization } - expect(response).to have_http_status(:ok) - fetched = JSON.parse(response.body) - expect(fetched['id']).to eq(created_id) - end + run_test! end response '422', 'validation error - missing account_id' do diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 575032edd..5874fcb90 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -55,6 +55,12 @@ RSpec.configure do |config| { type: :object } ], nullable: true + }, + errors: { + type: :array, + items: { type: :string }, + nullable: true, + description: 'Validation error messages (alternative to details used by trades, valuations, etc.)' } } }, @@ -417,6 +423,89 @@ RSpec.configure do |config| properties: { data: { '$ref' => '#/components/schemas/ImportDetail' } } + }, + Trade: { + type: :object, + required: %w[id date amount currency name qty price account created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + date: { type: :string, format: :date }, + amount: { type: :string }, + currency: { type: :string }, + name: { type: :string }, + notes: { type: :string, nullable: true }, + qty: { type: :string }, + price: { type: :string }, + investment_activity_label: { type: :string, nullable: true }, + account: { '$ref' => '#/components/schemas/Account' }, + security: { + type: :object, + nullable: true, + properties: { + id: { type: :string, format: :uuid }, + ticker: { type: :string }, + name: { type: :string, nullable: true } + } + }, + category: { + type: :object, + nullable: true, + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string } + } + }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + TradeCollection: { + type: :object, + required: %w[trades pagination], + properties: { + trades: { + type: :array, + items: { '$ref' => '#/components/schemas/Trade' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' } + } + }, + Holding: { + type: :object, + required: %w[id date qty price amount currency account security created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + date: { type: :string, format: :date }, + qty: { type: :string, description: 'Quantity of shares held' }, + price: { type: :string, description: 'Formatted price per share' }, + amount: { type: :string }, + currency: { type: :string }, + cost_basis_source: { type: :string, nullable: true }, + account: { '$ref' => '#/components/schemas/Account' }, + security: { + type: :object, + required: %w[id ticker name], + properties: { + id: { type: :string, format: :uuid }, + ticker: { type: :string }, + name: { type: :string, nullable: true } + } + }, + avg_cost: { type: :string, nullable: true }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + HoldingCollection: { + type: :object, + required: %w[holdings pagination], + properties: { + holdings: { + type: :array, + items: { '$ref' => '#/components/schemas/Holding' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' } + } } } }