mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
* Add REST API for holdings and trades (Discussion #905) - Trades: GET index (filter by account_id, account_ids, start_date, end_date), GET show, POST create (buy/sell with security_id or ticker), PATCH update, DELETE destroy. Create restricted to accounts that support trades (investment or crypto exchange). Uses existing Trade::CreateForm for creation. - Holdings: GET index (filter by account_id, account_ids, date, start_date, end_date, security_id), GET show. Read-only; scoped to family. - Auth: read scope for index/show; write scope for create/update/destroy. - Responses: JSON via jbuilder (trade: id, date, amount, qty, price, account, security, category; holding: id, date, qty, price, amount, account, security, avg_cost). Pagination for index endpoints (page, per_page). Co-authored-by: Cursor <cursoragent@cursor.com> * API v1 holdings & trades: validation, docs, specs - Holdings: validate date params, return 400 for invalid dates (parse_date!) - Trades: validate start_date/end_date, return 422 for invalid dates - Trades: accept buy/sell and inflow/outflow in update (trade_sell_from_type_or_nature?) - Trades view: nil guard for trade.security - Trades apply_filters: single join(:entry) when filtering - OpenAPI: add Trade/TradeCollection schemas, ErrorResponse.errors - Add spec/requests/api/v1/holdings_spec.rb and trades_spec.rb (rswag) - Regenerate docs/api/openapi.yaml Co-authored-by: Cursor <cursoragent@cursor.com> * CI: fix Brakeman and test rate-limit failures - Disable Rack::Attack in test (use existing enabled flag) so parallel API tests no longer hit 429 from shared api_ip throttle - Add Brakeman ignore for trades_controller trade_params mass-assignment (account_id/security_id validated in create/update) - Trades/holdings API and OpenAPI spec updates Co-authored-by: Cursor <cursoragent@cursor.com> * Trades: partial qty/price update fallback; fix PATCH OpenAPI schema - Fall back to existing trade qty/price when only one is supplied so sign normalisation and amount recalculation always run - OpenAPI: remove top-level qty, price, investment_activity_label, category_id from PATCH body; document entryable_attributes only Co-authored-by: Cursor <cursoragent@cursor.com> * Trades: fix update/DELETE OpenAPI and avoid sell-trade corruption - Only run qty/price normalisation when client sends qty or price; preserve existing trade direction when type/nature omitted - OpenAPI: remove duplicate PATCH path param; add 422 for PATCH; document DELETE 200 body (DeleteResponse) Co-authored-by: Cursor <cursoragent@cursor.com> * API: flat trade update params, align holdings errors, spec/OpenAPI fixes - Trades update: accept flat params (qty, price, type, etc.), build entryable_attributes in build_entry_params_for_update (match transactions) - Holdings: ArgumentError → 422 validation_failed; parse_date!(value, name) with safe message; extract render_validation_error, log_and_render_error - Specs: path id required (trades, holdings); trades delete 200 DeleteResponse; remove holdings 500; trades update body flat; holdings 422 invalid date - OpenAPI: PATCH trade request body flat Co-authored-by: Cursor <cursoragent@cursor.com> * OpenAPI: add 422 invalid date filter to holdings index Co-authored-by: Cursor <cursoragent@cursor.com> * API consistency and RSwag doc-only fixes - Trades: use render_validation_error in all 4 validation paths; safe_per_page_param case/when - Holdings: set_holding to family.holdings.find; price as Money.format in API; safe_per_page_param case/when - Swagger: Holding qty/price descriptions (Quantity of shares held, Formatted price per share) - RSwag: trades delete and valuations 201 use bare run_test! (documentation only, no expect) Co-authored-by: Cursor <cursoragent@cursor.com> * Fix index-vs-show visibility inconsistencies and preserve custom activity labels - Add account status filter to set_holding to match index behavior - Add visible scope to set_trade to match index behavior - Preserve existing investment_activity_label when updating qty/price Co-authored-by: Cursor <cursoragent@cursor.com> * Trades: clearer validation for non-numeric qty/price Return 'must be valid numbers' when qty or price is non-numeric (e.g. abc) instead of misleading 'must be present and positive'. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: mkdev11 <jaysmth689+github@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
108
app/controllers/api/v1/holdings_controller.rb
Normal file
108
app/controllers/api/v1/holdings_controller.rb
Normal file
@@ -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
|
||||
313
app/controllers/api/v1/trades_controller.rb
Normal file
313
app/controllers/api/v1/trades_controller.rb
Normal file
@@ -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
|
||||
27
app/views/api/v1/holdings/_holding.json.jbuilder
Normal file
27
app/views/api/v1/holdings/_holding.json.jbuilder
Normal file
@@ -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
|
||||
12
app/views/api/v1/holdings/index.json.jbuilder
Normal file
12
app/views/api/v1/holdings/index.json.jbuilder
Normal file
@@ -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
|
||||
3
app/views/api/v1/holdings/show.json.jbuilder
Normal file
3
app/views/api/v1/holdings/show.json.jbuilder
Normal file
@@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.partial! "holding", holding: @holding
|
||||
39
app/views/api/v1/trades/_trade.json.jbuilder
Normal file
39
app/views/api/v1/trades/_trade.json.jbuilder
Normal file
@@ -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
|
||||
12
app/views/api/v1/trades/index.json.jbuilder
Normal file
12
app/views/api/v1/trades/index.json.jbuilder
Normal file
@@ -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
|
||||
3
app/views/api/v1/trades/show.json.jbuilder
Normal file
3
app/views/api/v1/trades/show.json.jbuilder
Normal file
@@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.partial! "trade", trade: @trade
|
||||
@@ -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,
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
184
spec/requests/api/v1/holdings_spec.rb
Normal file
184
spec/requests/api/v1/holdings_spec.rb
Normal file
@@ -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
|
||||
394
spec/requests/api/v1/trades_spec.rb
Normal file
394
spec/requests/api/v1/trades_spec.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user