mirror of
https://github.com/we-promise/sure.git
synced 2026-05-07 21:04:12 +00:00
feat(api): expose complete account export state (#1597)
* feat(api): expose complete account export state * fix(api): handle malformed account identifiers * fix(api): tighten account export contracts * fix(api): correct account id OpenAPI format * fix(api): tighten account docs auth contracts * docs(api): document balance sheet auth errors * docs(api): clarify account scope fixture
This commit is contained in:
@@ -7,53 +7,86 @@ class Api::V1::AccountsController < Api::V1::BaseController
|
||||
before_action :ensure_read_scope
|
||||
|
||||
def index
|
||||
# Test with Pagy pagination
|
||||
family = current_resource_owner.family
|
||||
accounts_query = family.accounts.accessible_by(current_resource_owner).visible.alphabetically
|
||||
|
||||
# Handle pagination with Pagy
|
||||
@pagy, @accounts = pagy(
|
||||
accounts_query,
|
||||
page: safe_page_param,
|
||||
limit: safe_per_page_param
|
||||
)
|
||||
|
||||
@per_page = safe_per_page_param
|
||||
|
||||
# Rails will automatically use app/views/api/v1/accounts/index.json.jbuilder
|
||||
@pagy, @accounts = pagy(
|
||||
accounts_scope.alphabetically,
|
||||
page: safe_page_param,
|
||||
limit: @per_page
|
||||
)
|
||||
|
||||
render :index
|
||||
rescue => e
|
||||
Rails.logger.error "AccountsController error: #{e.message}"
|
||||
Rails.logger.error "AccountsController#index error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
render json: {
|
||||
error: "internal_server_error",
|
||||
message: "An unexpected error occurred"
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_read_scope
|
||||
authorize_scope!(:read)
|
||||
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
|
||||
|
||||
# Default to 25, max 100
|
||||
case per_page
|
||||
when 1..100
|
||||
per_page
|
||||
else
|
||||
25
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
unless valid_uuid?(params[:id])
|
||||
render json: {
|
||||
error: "not_found",
|
||||
message: "Account not found"
|
||||
}, status: :not_found
|
||||
return
|
||||
end
|
||||
|
||||
@account = accounts_scope.find(params[:id])
|
||||
|
||||
render :show
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: {
|
||||
error: "not_found",
|
||||
message: "Account not found"
|
||||
}, status: :not_found
|
||||
rescue => e
|
||||
Rails.logger.error "AccountsController#show error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
render json: {
|
||||
error: "internal_server_error",
|
||||
message: "An unexpected error occurred"
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_read_scope
|
||||
authorize_scope!(:read)
|
||||
end
|
||||
|
||||
def accounts_scope
|
||||
scope = current_resource_owner.family.accounts
|
||||
.accessible_by(current_resource_owner)
|
||||
.includes(:accountable, account_providers: :provider)
|
||||
include_disabled_accounts? ? scope : scope.visible
|
||||
end
|
||||
|
||||
def include_disabled_accounts?
|
||||
ActiveModel::Type::Boolean.new.cast(params[:include_disabled])
|
||||
end
|
||||
|
||||
def valid_uuid?(value)
|
||||
value.to_s.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
|
||||
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
|
||||
|
||||
20
app/views/api/v1/accounts/_account.json.jbuilder
Normal file
20
app/views/api/v1/accounts/_account.json.jbuilder
Normal file
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
balance_money = account.balance_money
|
||||
cash_balance_money = account.cash_balance_money
|
||||
|
||||
json.id account.id
|
||||
json.name account.name
|
||||
json.balance balance_money.format
|
||||
json.balance_cents((balance_money.amount * balance_money.currency.minor_unit_conversion).round(0).to_i)
|
||||
json.cash_balance cash_balance_money.format
|
||||
json.cash_balance_cents((cash_balance_money.amount * cash_balance_money.currency.minor_unit_conversion).round(0).to_i)
|
||||
json.currency account.currency
|
||||
json.classification account.classification
|
||||
json.account_type account.accountable_type&.underscore
|
||||
json.subtype account.subtype
|
||||
json.status account.status
|
||||
json.institution_name account.institution_name
|
||||
json.institution_domain account.institution_domain
|
||||
json.created_at account.created_at.iso8601
|
||||
json.updated_at account.updated_at.iso8601
|
||||
@@ -1,12 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.accounts @accounts do |account|
|
||||
json.id account.id
|
||||
json.name account.name
|
||||
json.balance account.balance_money.format
|
||||
json.currency account.currency
|
||||
json.classification account.classification
|
||||
json.account_type account.accountable_type.underscore
|
||||
json.partial! "account", account: account
|
||||
end
|
||||
|
||||
json.pagination do
|
||||
|
||||
3
app/views/api/v1/accounts/show.json.jbuilder
Normal file
3
app/views/api/v1/accounts/show.json.jbuilder
Normal file
@@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.partial! "account", account: @account
|
||||
@@ -61,6 +61,16 @@ components:
|
||||
nullable: true
|
||||
description: Validation error messages (alternative to details used by trades,
|
||||
valuations, etc.)
|
||||
MfaRequiredResponse:
|
||||
type: object
|
||||
required:
|
||||
- error
|
||||
- mfa_required
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
mfa_required:
|
||||
type: boolean
|
||||
ToolCall:
|
||||
type: object
|
||||
required:
|
||||
@@ -230,15 +240,24 @@ components:
|
||||
type: string
|
||||
account_type:
|
||||
type: string
|
||||
nullable: true
|
||||
status:
|
||||
type: string
|
||||
AccountDetail:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- balance
|
||||
- balance_cents
|
||||
- cash_balance
|
||||
- cash_balance_cents
|
||||
- currency
|
||||
- classification
|
||||
- account_type
|
||||
- status
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@@ -247,12 +266,43 @@ components:
|
||||
type: string
|
||||
balance:
|
||||
type: string
|
||||
balance_cents:
|
||||
type: integer
|
||||
description: Signed balance in minor currency units
|
||||
cash_balance:
|
||||
type: string
|
||||
cash_balance_cents:
|
||||
type: integer
|
||||
description: Signed cash balance in minor currency units
|
||||
currency:
|
||||
type: string
|
||||
classification:
|
||||
type: string
|
||||
account_type:
|
||||
type: string
|
||||
nullable: true
|
||||
subtype:
|
||||
type: string
|
||||
nullable: true
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- active
|
||||
- draft
|
||||
- disabled
|
||||
- pending_deletion
|
||||
institution_name:
|
||||
type: string
|
||||
nullable: true
|
||||
institution_domain:
|
||||
type: string
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
AccountCollection:
|
||||
type: object
|
||||
required:
|
||||
@@ -270,7 +320,6 @@ components:
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- classification
|
||||
- color
|
||||
- icon
|
||||
properties:
|
||||
@@ -279,8 +328,6 @@ components:
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
classification:
|
||||
type: string
|
||||
color:
|
||||
type: string
|
||||
icon:
|
||||
@@ -301,7 +348,6 @@ components:
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- classification
|
||||
- color
|
||||
- icon
|
||||
- subcategories_count
|
||||
@@ -313,11 +359,6 @@ components:
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
classification:
|
||||
type: string
|
||||
enum:
|
||||
- income
|
||||
- expense
|
||||
color:
|
||||
type: string
|
||||
icon:
|
||||
@@ -545,13 +586,6 @@ components:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
SuccessMessage:
|
||||
type: object
|
||||
required:
|
||||
- message
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
ImportConfiguration:
|
||||
type: object
|
||||
properties:
|
||||
@@ -879,50 +913,47 @@ components:
|
||||
"$ref": "#/components/schemas/Holding"
|
||||
pagination:
|
||||
"$ref": "#/components/schemas/Pagination"
|
||||
Money:
|
||||
type: object
|
||||
required:
|
||||
- amount
|
||||
- currency
|
||||
- formatted
|
||||
properties:
|
||||
amount:
|
||||
type: string
|
||||
description: Numeric amount as string
|
||||
currency:
|
||||
type: string
|
||||
description: ISO 4217 currency code
|
||||
formatted:
|
||||
type: string
|
||||
description: Locale-formatted money string
|
||||
BalanceSheet:
|
||||
type: object
|
||||
required:
|
||||
- currency
|
||||
- net_worth
|
||||
- assets
|
||||
- liabilities
|
||||
properties:
|
||||
currency:
|
||||
type: string
|
||||
description: Family primary currency
|
||||
net_worth:
|
||||
"$ref": "#/components/schemas/Money"
|
||||
assets:
|
||||
"$ref": "#/components/schemas/Money"
|
||||
liabilities:
|
||||
"$ref": "#/components/schemas/Money"
|
||||
SuccessMessage:
|
||||
type: object
|
||||
required:
|
||||
- message
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
paths:
|
||||
"/api/v1/merchants":
|
||||
get:
|
||||
summary: List merchants
|
||||
tags:
|
||||
- Merchants
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: merchants listed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
"$ref": "#/components/schemas/MerchantDetail"
|
||||
"/api/v1/merchants/{id}":
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Merchant ID
|
||||
schema:
|
||||
type: string
|
||||
get:
|
||||
summary: Retrieve a merchant
|
||||
tags:
|
||||
- Merchants
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: merchant retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/MerchantDetail"
|
||||
'404':
|
||||
description: merchant not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/accounts":
|
||||
get:
|
||||
summary: List accounts
|
||||
@@ -943,6 +974,12 @@ paths:
|
||||
description: 'Items per page (default: 25, max: 100)'
|
||||
schema:
|
||||
type: integer
|
||||
- name: include_disabled
|
||||
in: query
|
||||
required: false
|
||||
description: Include disabled accounts in the response. Defaults to false.
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: accounts paginated
|
||||
@@ -950,6 +987,53 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/AccountCollection"
|
||||
"/api/v1/accounts/{id}":
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Account ID
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
get:
|
||||
summary: Retrieve an account
|
||||
tags:
|
||||
- Accounts
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- name: include_disabled
|
||||
in: query
|
||||
required: false
|
||||
description: Allow retrieving a disabled account. Defaults to false.
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: account retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/AccountDetail"
|
||||
'401':
|
||||
description: unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'403':
|
||||
description: insufficient scope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'404':
|
||||
description: account not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/auth/signup":
|
||||
post:
|
||||
summary: Sign up a new user
|
||||
@@ -1267,6 +1351,181 @@ paths:
|
||||
- refresh_token
|
||||
- device
|
||||
required: true
|
||||
"/api/v1/auth/sso_link":
|
||||
post:
|
||||
summary: Link an existing account via SSO
|
||||
tags:
|
||||
- Auth
|
||||
description: Authenticates with email/password and links the SSO identity from
|
||||
a previously issued linking code. Creates an OidcIdentity, logs the link via
|
||||
SsoAuditLog, and issues mobile OAuth tokens.
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: account linked and tokens issued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
access_token:
|
||||
type: string
|
||||
refresh_token:
|
||||
type: string
|
||||
token_type:
|
||||
type: string
|
||||
expires_in:
|
||||
type: integer
|
||||
created_at:
|
||||
type: integer
|
||||
user:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
email:
|
||||
type: string
|
||||
first_name:
|
||||
type: string
|
||||
last_name:
|
||||
type: string
|
||||
ui_layout:
|
||||
type: string
|
||||
enum:
|
||||
- dashboard
|
||||
- intro
|
||||
ai_enabled:
|
||||
type: boolean
|
||||
'400':
|
||||
description: missing linking code
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'401':
|
||||
description: invalid credentials or expired linking code
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- "$ref": "#/components/schemas/ErrorResponse"
|
||||
- "$ref": "#/components/schemas/MfaRequiredResponse"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
linking_code:
|
||||
type: string
|
||||
description: One-time linking code from mobile SSO onboarding redirect
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
description: Email of the existing account to link
|
||||
password:
|
||||
type: string
|
||||
description: Password for the existing account
|
||||
required:
|
||||
- linking_code
|
||||
- email
|
||||
- password
|
||||
required: true
|
||||
"/api/v1/auth/sso_create_account":
|
||||
post:
|
||||
summary: Create a new account via SSO
|
||||
tags:
|
||||
- Auth
|
||||
description: Creates a new user and family from a previously issued linking
|
||||
code. Links the SSO identity via OidcIdentity, logs the JIT account creation
|
||||
via SsoAuditLog, and issues mobile OAuth tokens. The linking code must have
|
||||
allow_account_creation enabled.
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: account created and tokens issued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
access_token:
|
||||
type: string
|
||||
refresh_token:
|
||||
type: string
|
||||
token_type:
|
||||
type: string
|
||||
expires_in:
|
||||
type: integer
|
||||
created_at:
|
||||
type: integer
|
||||
user:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
email:
|
||||
type: string
|
||||
first_name:
|
||||
type: string
|
||||
last_name:
|
||||
type: string
|
||||
ui_layout:
|
||||
type: string
|
||||
enum:
|
||||
- dashboard
|
||||
- intro
|
||||
ai_enabled:
|
||||
type: boolean
|
||||
'400':
|
||||
description: missing linking code
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'401':
|
||||
description: invalid or expired linking code
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'403':
|
||||
description: account creation disabled
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'422':
|
||||
description: user validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
linking_code:
|
||||
type: string
|
||||
description: One-time linking code from mobile SSO onboarding redirect
|
||||
first_name:
|
||||
type: string
|
||||
description: First name (overrides value from SSO provider if provided)
|
||||
last_name:
|
||||
type: string
|
||||
description: Last name (overrides value from SSO provider if provided)
|
||||
required:
|
||||
- linking_code
|
||||
required: true
|
||||
"/api/v1/auth/enable_ai":
|
||||
patch:
|
||||
summary: Enable AI features for the authenticated user
|
||||
@@ -1309,6 +1568,34 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'403':
|
||||
description: insufficient scope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/balance_sheet":
|
||||
get:
|
||||
summary: Show balance sheet
|
||||
tags:
|
||||
- Balance Sheet
|
||||
description: Returns the family balance sheet including net worth, total assets,
|
||||
and total liabilities with amounts converted to the family's primary currency.
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: balance sheet returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/BalanceSheet"
|
||||
'401':
|
||||
description: unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/categories":
|
||||
get:
|
||||
summary: List categories
|
||||
@@ -1329,15 +1616,6 @@ paths:
|
||||
description: 'Items per page (default: 25, max: 100)'
|
||||
schema:
|
||||
type: integer
|
||||
- name: classification
|
||||
in: query
|
||||
required: false
|
||||
description: Filter by classification (income or expense)
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- income
|
||||
- expense
|
||||
- name: roots_only
|
||||
in: query
|
||||
required: false
|
||||
@@ -1883,6 +2161,49 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/merchants":
|
||||
get:
|
||||
summary: List merchants
|
||||
tags:
|
||||
- Merchants
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: merchants listed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
"$ref": "#/components/schemas/MerchantDetail"
|
||||
"/api/v1/merchants/{id}":
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Merchant ID
|
||||
schema:
|
||||
type: string
|
||||
get:
|
||||
summary: Retrieve a merchant
|
||||
tags:
|
||||
- Merchants
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: merchant retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/MerchantDetail"
|
||||
'404':
|
||||
description: merchant not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/tags":
|
||||
get:
|
||||
summary: List tags
|
||||
@@ -2583,6 +2904,53 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/users/reset":
|
||||
delete:
|
||||
summary: Reset account
|
||||
tags:
|
||||
- Users
|
||||
description: Resets all financial data (accounts, categories, merchants, tags,
|
||||
etc.) for the current user's family while keeping the user account intact.
|
||||
The reset runs asynchronously in the background. Requires admin role.
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: account reset initiated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/SuccessMessage"
|
||||
'401':
|
||||
description: unauthorized
|
||||
'403':
|
||||
description: forbidden - requires read_write scope and admin role
|
||||
"/api/v1/users/me":
|
||||
delete:
|
||||
summary: Delete account
|
||||
tags:
|
||||
- Users
|
||||
description: Permanently deactivates the current user account and all associated
|
||||
data. This action cannot be undone.
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: account deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/SuccessMessage"
|
||||
'401':
|
||||
description: unauthorized
|
||||
'403':
|
||||
description: insufficient scope
|
||||
'422':
|
||||
description: deactivation failed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/valuations":
|
||||
post:
|
||||
summary: Create valuation
|
||||
@@ -2725,66 +3093,3 @@ paths:
|
||||
type: string
|
||||
description: Additional notes
|
||||
required: true
|
||||
"/api/v1/users/reset":
|
||||
delete:
|
||||
summary: Reset account
|
||||
tags:
|
||||
- Users
|
||||
description: Resets all financial data (accounts, categories, merchants, tags,
|
||||
etc.) for the current user's family while keeping the user account intact.
|
||||
The reset runs asynchronously in the background. Requires admin role.
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: account reset initiated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/SuccessMessage"
|
||||
'401':
|
||||
description: unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'403':
|
||||
description: "forbidden \u2014 requires read_write scope and admin role"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
"/api/v1/users/me":
|
||||
delete:
|
||||
summary: Delete account
|
||||
tags:
|
||||
- Users
|
||||
description: Permanently deactivates the current user account and all associated
|
||||
data. This action cannot be undone.
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: account deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/SuccessMessage"
|
||||
'401':
|
||||
description: unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'403':
|
||||
description: insufficient scope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
'422':
|
||||
description: deactivation failed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
|
||||
@@ -31,6 +31,19 @@ RSpec.describe 'API V1 Accounts', type: :request do
|
||||
)
|
||||
end
|
||||
|
||||
let(:api_key_without_read_scope) do
|
||||
key = ApiKey.generate_secure_key
|
||||
# Valid persisted API keys can only be read/read_write; this intentionally
|
||||
# bypasses validations to document the runtime insufficient-scope response.
|
||||
ApiKey.new(
|
||||
user: user,
|
||||
name: 'No Read Docs Key',
|
||||
key: key,
|
||||
scopes: %w[write],
|
||||
source: 'web'
|
||||
).tap { |api_key| api_key.save!(validate: false) }
|
||||
end
|
||||
|
||||
let(:'X-Api-Key') { api_key.plain_key }
|
||||
|
||||
let!(:checking_account) do
|
||||
@@ -72,6 +85,8 @@ RSpec.describe 'API V1 Accounts', type: :request do
|
||||
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: :include_disabled, in: :query, type: :boolean, required: false,
|
||||
description: 'Include disabled accounts in the response. Defaults to false.'
|
||||
|
||||
response '200', 'accounts listed' do
|
||||
schema '$ref' => '#/components/schemas/AccountCollection'
|
||||
@@ -89,4 +104,51 @@ RSpec.describe 'API V1 Accounts', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/accounts/{id}' do
|
||||
parameter name: :id, in: :path, required: true, description: 'Account ID',
|
||||
schema: { type: :string, format: :uuid }
|
||||
|
||||
get 'Retrieve an account' do
|
||||
tags 'Accounts'
|
||||
security [ { apiKeyAuth: [] } ]
|
||||
produces 'application/json'
|
||||
parameter name: :include_disabled, in: :query, type: :boolean, required: false,
|
||||
description: 'Allow retrieving a disabled account. Defaults to false.'
|
||||
|
||||
let(:id) { checking_account.id }
|
||||
|
||||
response '200', 'account retrieved' do
|
||||
schema '$ref' => '#/components/schemas/AccountDetail'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:id) { checking_account.id }
|
||||
let(:'X-Api-Key') { nil }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '403', 'insufficient scope' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:id) { checking_account.id }
|
||||
let(:'X-Api-Key') { api_key_without_read_scope.plain_key }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '404', 'account not found' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:id) { SecureRandom.uuid }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -261,7 +261,10 @@ RSpec.describe 'API V1 Auth', type: :request do
|
||||
end
|
||||
|
||||
response '401', 'invalid credentials or expired linking code' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
schema oneOf: [
|
||||
{ '$ref' => '#/components/schemas/ErrorResponse' },
|
||||
{ '$ref' => '#/components/schemas/MfaRequiredResponse' }
|
||||
]
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
@@ -48,6 +48,8 @@ RSpec.describe 'API V1 Balance Sheet', type: :request do
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
schema '$ref' => '#/components/schemas/ErrorResponse'
|
||||
|
||||
let(:'X-Api-Key') { 'invalid-key' }
|
||||
|
||||
run_test!
|
||||
|
||||
@@ -64,6 +64,14 @@ RSpec.configure do |config|
|
||||
}
|
||||
}
|
||||
},
|
||||
MfaRequiredResponse: {
|
||||
type: :object,
|
||||
required: %w[error mfa_required],
|
||||
properties: {
|
||||
error: { type: :string },
|
||||
mfa_required: { type: :boolean }
|
||||
}
|
||||
},
|
||||
ToolCall: {
|
||||
type: :object,
|
||||
required: %w[id function_name function_arguments created_at],
|
||||
@@ -175,19 +183,29 @@ RSpec.configure do |config|
|
||||
properties: {
|
||||
id: { type: :string, format: :uuid },
|
||||
name: { type: :string },
|
||||
account_type: { type: :string }
|
||||
account_type: { type: :string, nullable: true },
|
||||
status: { type: :string }
|
||||
}
|
||||
},
|
||||
AccountDetail: {
|
||||
type: :object,
|
||||
required: %w[id name balance currency classification account_type],
|
||||
required: %w[id name balance balance_cents cash_balance cash_balance_cents currency classification account_type status created_at updated_at],
|
||||
properties: {
|
||||
id: { type: :string, format: :uuid },
|
||||
name: { type: :string },
|
||||
balance: { type: :string },
|
||||
balance_cents: { type: :integer, description: 'Signed balance in minor currency units' },
|
||||
cash_balance: { type: :string },
|
||||
cash_balance_cents: { type: :integer, description: 'Signed cash balance in minor currency units' },
|
||||
currency: { type: :string },
|
||||
classification: { type: :string },
|
||||
account_type: { type: :string }
|
||||
account_type: { type: :string, nullable: true },
|
||||
subtype: { type: :string, nullable: true },
|
||||
status: { type: :string, enum: %w[active draft disabled pending_deletion] },
|
||||
institution_name: { type: :string, nullable: true },
|
||||
institution_domain: { type: :string, nullable: true },
|
||||
created_at: { type: :string, format: :'date-time' },
|
||||
updated_at: { type: :string, format: :'date-time' }
|
||||
}
|
||||
},
|
||||
AccountCollection: {
|
||||
|
||||
@@ -8,10 +8,22 @@ class Api::V1::AccountsControllerTest < ActionDispatch::IntegrationTest
|
||||
@other_family_user = users(:family_member)
|
||||
@other_family_user.update!(family: families(:empty))
|
||||
|
||||
@oauth_app = Doorkeeper::Application.create!(
|
||||
name: "Test API App",
|
||||
redirect_uri: "https://example.com/callback",
|
||||
scopes: "read read_write"
|
||||
@user.api_keys.active.destroy_all
|
||||
@api_key = ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Test Read Key",
|
||||
scopes: [ "read" ],
|
||||
source: "web",
|
||||
display_key: "test_read_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
|
||||
@other_family_user.api_keys.active.destroy_all
|
||||
@other_family_api_key = ApiKey.create!(
|
||||
user: @other_family_user,
|
||||
name: "Other Family Read Key",
|
||||
scopes: [ "read" ],
|
||||
source: "web",
|
||||
display_key: "other_family_read_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
end
|
||||
|
||||
@@ -24,37 +36,28 @@ class Api::V1::AccountsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "should require read_accounts scope" do
|
||||
# TODO: Re-enable this test after fixing scope checking
|
||||
skip "Scope checking temporarily disabled - needs configuration fix"
|
||||
api_key_without_read = ApiKey.new(
|
||||
user: @user,
|
||||
name: "No Read Key",
|
||||
scopes: [],
|
||||
source: "web",
|
||||
display_key: "no_read_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
# Valid persisted API keys can only be read/read_write; this intentionally
|
||||
# bypasses validations to exercise the runtime insufficient-scope guard.
|
||||
api_key_without_read.save!(validate: false)
|
||||
|
||||
# Create token with wrong scope - using a non-existent scope to test rejection
|
||||
access_token = Doorkeeper::AccessToken.create!(
|
||||
application: @oauth_app,
|
||||
resource_owner_id: @user.id,
|
||||
scopes: "invalid_scope" # Wrong scope
|
||||
)
|
||||
get "/api/v1/accounts", params: {}, headers: api_headers(api_key_without_read)
|
||||
|
||||
get "/api/v1/accounts", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{access_token.token}"
|
||||
}
|
||||
|
||||
assert_response :forbidden
|
||||
|
||||
# Doorkeeper returns a standard OAuth error response
|
||||
response_body = JSON.parse(response.body)
|
||||
assert_equal "insufficient_scope", response_body["error"]
|
||||
end
|
||||
assert_response :forbidden
|
||||
response_body = JSON.parse(response.body)
|
||||
assert_equal "insufficient_scope", response_body["error"]
|
||||
ensure
|
||||
api_key_without_read&.destroy
|
||||
end
|
||||
|
||||
test "should return user's family accounts successfully" do
|
||||
access_token = Doorkeeper::AccessToken.create!(
|
||||
application: @oauth_app,
|
||||
resource_owner_id: @user.id,
|
||||
scopes: "read"
|
||||
)
|
||||
|
||||
get "/api/v1/accounts", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{access_token.token}"
|
||||
}
|
||||
get "/api/v1/accounts", params: {}, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
@@ -83,15 +86,7 @@ end
|
||||
inactive_account = accounts(:depository)
|
||||
inactive_account.disable!
|
||||
|
||||
access_token = Doorkeeper::AccessToken.create!(
|
||||
application: @oauth_app,
|
||||
resource_owner_id: @user.id,
|
||||
scopes: "read"
|
||||
)
|
||||
|
||||
get "/api/v1/accounts", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{access_token.token}"
|
||||
}
|
||||
get "/api/v1/accounts", params: {}, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
@@ -101,17 +96,140 @@ end
|
||||
assert_not_includes account_names, inactive_account.name
|
||||
end
|
||||
|
||||
test "should not return other family's accounts" do
|
||||
access_token = Doorkeeper::AccessToken.create!(
|
||||
application: @oauth_app,
|
||||
resource_owner_id: @other_family_user.id, # User from different family
|
||||
scopes: "read"
|
||||
)
|
||||
test "should include disabled accounts when requested" do
|
||||
inactive_account = accounts(:depository)
|
||||
inactive_account.disable!
|
||||
|
||||
get "/api/v1/accounts", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{access_token.token}"
|
||||
get "/api/v1/accounts", params: { include_disabled: true }, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
|
||||
account = response_body["accounts"].find { |account_data| account_data["id"] == inactive_account.id }
|
||||
assert_not_nil account
|
||||
assert_equal "disabled", account["status"]
|
||||
end
|
||||
|
||||
test "should show active account" do
|
||||
account = accounts(:depository)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}", headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
assert_equal account.id, response_body["id"]
|
||||
assert_equal account.status, response_body["status"]
|
||||
assert_equal account.balance_money.format, response_body["balance"]
|
||||
assert_equal money_cents(account.balance_money), response_body["balance_cents"]
|
||||
assert_equal account.cash_balance_money.format, response_body["cash_balance"]
|
||||
assert_equal money_cents(account.cash_balance_money), response_body["cash_balance_cents"]
|
||||
assert_nullable_equal account.subtype, response_body["subtype"]
|
||||
assert response_body.key?("institution_name")
|
||||
assert response_body.key?("institution_domain")
|
||||
assert_nullable_equal account.institution_name, response_body["institution_name"]
|
||||
assert_nullable_equal account.institution_domain, response_body["institution_domain"]
|
||||
assert_equal account.created_at.iso8601, response_body["created_at"]
|
||||
assert_equal account.updated_at.iso8601, response_body["updated_at"]
|
||||
end
|
||||
|
||||
test "should return 404 for unknown account on show" do
|
||||
get "/api/v1/accounts/#{SecureRandom.uuid}", headers: api_headers(@api_key)
|
||||
|
||||
assert_response :not_found
|
||||
response_body = JSON.parse(response.body)
|
||||
assert_equal "not_found", response_body["error"]
|
||||
end
|
||||
|
||||
test "should return 404 for malformed account id on show" do
|
||||
get "/api/v1/accounts/not-a-uuid", headers: api_headers(@api_key)
|
||||
|
||||
assert_response :not_found
|
||||
response_body = JSON.parse(response.body)
|
||||
assert_equal "not_found", response_body["error"]
|
||||
assert_equal "Account not found", response_body["message"]
|
||||
end
|
||||
|
||||
test "should require authentication on show" do
|
||||
account = accounts(:depository)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}"
|
||||
|
||||
assert_response :unauthorized
|
||||
response_body = JSON.parse(response.body)
|
||||
assert_equal "unauthorized", response_body["error"]
|
||||
end
|
||||
|
||||
test "should require read scope on show" do
|
||||
account = accounts(:depository)
|
||||
api_key_without_read = ApiKey.new(
|
||||
user: @user,
|
||||
name: "No Read Show Key",
|
||||
scopes: [],
|
||||
source: "web",
|
||||
display_key: "no_read_show_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
# Valid persisted API keys can only be read/read_write; this intentionally
|
||||
# bypasses validations to exercise the runtime insufficient-scope guard.
|
||||
api_key_without_read.save!(validate: false)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}", headers: api_headers(api_key_without_read)
|
||||
|
||||
assert_response :forbidden
|
||||
response_body = JSON.parse(response.body)
|
||||
assert_equal "insufficient_scope", response_body["error"]
|
||||
ensure
|
||||
api_key_without_read&.destroy
|
||||
end
|
||||
|
||||
test "should hide disabled account by default on show" do
|
||||
inactive_account = accounts(:depository)
|
||||
inactive_account.disable!
|
||||
|
||||
get "/api/v1/accounts/#{inactive_account.id}", headers: api_headers(@api_key)
|
||||
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
test "should show disabled account when requested" do
|
||||
inactive_account = accounts(:depository)
|
||||
inactive_account.disable!
|
||||
|
||||
get "/api/v1/accounts/#{inactive_account.id}",
|
||||
params: { include_disabled: true },
|
||||
headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
assert_equal inactive_account.id, response_body["id"]
|
||||
assert_equal "disabled", response_body["status"]
|
||||
end
|
||||
|
||||
test "should expose subtype across account types" do
|
||||
expected_subtypes = {
|
||||
accounts(:depository) => "checking",
|
||||
accounts(:credit_card) => "credit_card",
|
||||
accounts(:investment) => "brokerage",
|
||||
accounts(:loan) => "mortgage",
|
||||
accounts(:property) => "single_family_home",
|
||||
accounts(:vehicle) => "sedan",
|
||||
accounts(:crypto) => "exchange",
|
||||
accounts(:other_asset) => "collectible",
|
||||
accounts(:other_liability) => "personal_debt"
|
||||
}
|
||||
|
||||
expected_subtypes.each { |account, subtype| account.accountable.update!(subtype: subtype) }
|
||||
|
||||
expected_subtypes.each do |account, subtype|
|
||||
get "/api/v1/accounts/#{account.id}", headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
assert_equal subtype, JSON.parse(response.body)["subtype"]
|
||||
end
|
||||
end
|
||||
|
||||
test "should not return other family's accounts" do
|
||||
get "/api/v1/accounts", params: {}, headers: api_headers(@other_family_api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
|
||||
@@ -121,16 +239,8 @@ end
|
||||
end
|
||||
|
||||
test "should handle pagination parameters" do
|
||||
access_token = Doorkeeper::AccessToken.create!(
|
||||
application: @oauth_app,
|
||||
resource_owner_id: @user.id,
|
||||
scopes: "read"
|
||||
)
|
||||
|
||||
# Test with pagination params
|
||||
get "/api/v1/accounts", params: { page: 1, per_page: 2 }, headers: {
|
||||
"Authorization" => "Bearer #{access_token.token}"
|
||||
}
|
||||
get "/api/v1/accounts", params: { page: 1, per_page: 2 }, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
@@ -142,15 +252,7 @@ end
|
||||
end
|
||||
|
||||
test "should return proper account data structure" do
|
||||
access_token = Doorkeeper::AccessToken.create!(
|
||||
application: @oauth_app,
|
||||
resource_owner_id: @user.id,
|
||||
scopes: "read"
|
||||
)
|
||||
|
||||
get "/api/v1/accounts", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{access_token.token}"
|
||||
}
|
||||
get "/api/v1/accounts", params: {}, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
@@ -161,7 +263,7 @@ end
|
||||
account = response_body["accounts"].first
|
||||
|
||||
# Check required fields are present
|
||||
required_fields = %w[id name balance currency classification account_type]
|
||||
required_fields = %w[id name balance balance_cents cash_balance cash_balance_cents currency classification account_type]
|
||||
required_fields.each do |field|
|
||||
assert account.key?(field), "Account should have #{field} field"
|
||||
end
|
||||
@@ -170,21 +272,15 @@ end
|
||||
assert account["id"].is_a?(String), "ID should be string (UUID)"
|
||||
assert account["name"].is_a?(String), "Name should be string"
|
||||
assert account["balance"].is_a?(String), "Balance should be string (money)"
|
||||
assert account["balance_cents"].is_a?(Integer), "Balance cents should be integer"
|
||||
assert account["cash_balance_cents"].is_a?(Integer), "Cash balance cents should be integer"
|
||||
assert account["currency"].is_a?(String), "Currency should be string"
|
||||
assert %w[asset liability].include?(account["classification"]), "Classification should be asset or liability"
|
||||
end
|
||||
|
||||
test "should handle invalid pagination parameters gracefully" do
|
||||
access_token = Doorkeeper::AccessToken.create!(
|
||||
application: @oauth_app,
|
||||
resource_owner_id: @user.id,
|
||||
scopes: "read"
|
||||
)
|
||||
|
||||
# Test with invalid page number
|
||||
get "/api/v1/accounts", params: { page: -1, per_page: "invalid" }, headers: {
|
||||
"Authorization" => "Bearer #{access_token.token}"
|
||||
}
|
||||
get "/api/v1/accounts", params: { page: -1, per_page: "invalid" }, headers: api_headers(@api_key)
|
||||
|
||||
# Should still return success with default pagination
|
||||
assert_response :success
|
||||
@@ -197,15 +293,7 @@ end
|
||||
end
|
||||
|
||||
test "should sort accounts alphabetically" do
|
||||
access_token = Doorkeeper::AccessToken.create!(
|
||||
application: @oauth_app,
|
||||
resource_owner_id: @user.id,
|
||||
scopes: "read"
|
||||
)
|
||||
|
||||
get "/api/v1/accounts", params: {}, headers: {
|
||||
"Authorization" => "Bearer #{access_token.token}"
|
||||
}
|
||||
get "/api/v1/accounts", params: {}, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
@@ -214,4 +302,18 @@ end
|
||||
account_names = response_body["accounts"].map { |a| a["name"] }
|
||||
assert_equal account_names.sort, account_names
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def api_headers(api_key)
|
||||
{ "X-Api-Key" => api_key.plain_key }
|
||||
end
|
||||
|
||||
def money_cents(money)
|
||||
(money.amount * money.currency.minor_unit_conversion).round(0).to_i
|
||||
end
|
||||
|
||||
def assert_nullable_equal(expected, actual)
|
||||
expected.nil? ? assert_nil(actual) : assert_equal(expected, actual)
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user