Add REST API for holdings and trades (Discussion #905) (#918)

* 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:
MkDev11
2026-02-08 02:22:32 -08:00
committed by GitHub
parent 24981ffd52
commit d88c2151cb
16 changed files with 1752 additions and 11 deletions

View 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

View 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

View 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

View 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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View File

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