Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bd947b4
feat(statements): add account statement vault
JSONbored May 5, 2026
547c4c5
fix(statements): return deleted account statements to inbox
JSONbored May 5, 2026
392a950
fix(statements): harden vault upload review flows
JSONbored May 5, 2026
c7976ed
fix(statements): harden vault upload and access controls
JSONbored May 5, 2026
f727dd4
fix(statements): address vault hardening review
JSONbored May 5, 2026
1c230d7
fix(statements): address vault review feedback
JSONbored May 6, 2026
98ad933
fix(statements): harden vault review follow-ups
JSONbored May 6, 2026
b4ef98e
fix(statements): repair settings system coverage
JSONbored May 6, 2026
2c549cd
fix(statements): move vault beside accounts
JSONbored May 6, 2026
116fab2
fix(statements): address vault review cleanup
JSONbored May 6, 2026
c530e36
fix(statements): address vault cleanup review
JSONbored May 6, 2026
9d98d48
fix(statements): deduplicate vault style helpers
JSONbored May 6, 2026
e1f4965
fix(statements): close vault review follow-ups
JSONbored May 7, 2026
e9321da
fix(statements): refresh schema after upstream rebase
JSONbored May 9, 2026
f020a4d
fix(statements): process vault uploads sequentially
JSONbored May 11, 2026
4cbcf36
fix(statements): close vault review follow-ups
JSONbored May 11, 2026
7b95bfa
fix(statements): scope vault index to accessible accounts
JSONbored May 11, 2026
a508ef6
fix(statements): harden statement vault readiness
JSONbored May 12, 2026
b27d3bd
Merge branch 'main' into codex/feat-account-statement-vault-recreated
JSONbored May 12, 2026
0b049f6
fix(statements): close vault review follow-ups
JSONbored May 12, 2026
0f0d8cc
fix(statements): address vault scan follow-ups
JSONbored May 12, 2026
4107c6f
Merge branch 'main' into codex/feat-account-statement-vault-recreated
JSONbored May 12, 2026
0e4be85
fix(statements): defer vault tab loading
JSONbored May 12, 2026
85431f4
Merge branch 'main' into codex/feat-account-statement-vault-recreated
jjmata May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ FROM ruby:${RUBY_VERSION}-slim-bookworm

ENV DEBIAN_FRONTEND=noninteractive

# libvips42 supports ActiveStorage image variants through image_processing/ruby-vips
# for existing profile/avatar images. AccountStatement.original_file only stores
# PDF/CSV/XLSX originals, but the devcontainer needs this broader image stack.
RUN apt-get update -qq \
&& apt-get -y install --no-install-recommends \
apt-utils \
Expand All @@ -11,6 +14,7 @@ RUN apt-get update -qq \
git \
imagemagick \
iproute2 \
libvips42 \
libpq-dev \
libyaml-dev \
libyaml-0-2 \
Expand Down
3 changes: 3 additions & 0 deletions .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ services:
- "7900:7900"
shm_size: 2gb
restart: unless-stopped
environment:
SE_NODE_MAX_SESSIONS: 4
SE_NODE_OVERRIDE_MAX_SESSIONS: "true"

volumes:
postgres-data:
Expand Down
2 changes: 1 addition & 1 deletion app/components/UI/account_page.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<%= render DS::Tabs.new(active_tab: active_tab, url_param_key: "tab") do |tabs_container| %>
<% tabs_container.with_nav(classes: "max-w-fit") do |nav| %>
<% tabs.each do |tab| %>
<% nav.with_btn(id: tab, label: tab.to_s.humanize, classes: "px-6") %>
<% nav.with_btn(id: tab, label: t("accounts.show.tabs.#{tab}", default: tab.to_s.humanize), classes: "px-6") %>
<% end %>
<% end %>

Expand Down
21 changes: 18 additions & 3 deletions app/components/UI/account_page.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
class UI::AccountPage < ApplicationComponent
attr_reader :account, :chart_view, :chart_period
attr_reader :account, :chart_view, :chart_period, :statement_coverage, :statements, :reconciliation_statuses,
:can_manage_statements

renders_one :activity_feed, ->(feed_data:, pagy:, search:) { UI::Account::ActivityFeed.new(feed_data: feed_data, pagy: pagy, search: search) }

def initialize(account:, chart_view: nil, chart_period: nil, active_tab: nil)
def initialize(account:, chart_view: nil, chart_period: nil, active_tab: nil, statement_coverage: nil, statements: [],
reconciliation_statuses: {}, can_manage_statements: false)
@account = account
@chart_view = chart_view
@chart_period = chart_period
@active_tab = active_tab
@statement_coverage = statement_coverage
@statements = statements
@reconciliation_statuses = reconciliation_statuses
@can_manage_statements = can_manage_statements
end

def id
Expand Down Expand Up @@ -37,14 +43,16 @@ def active_tab
end

def tabs
case account.accountable_type
base_tabs = case account.accountable_type
when "Investment", "Crypto"
[ :activity, :holdings ]
when "Property", "Vehicle", "Loan"
[ :activity, :overview ]
else
[ :activity ]
end

base_tabs + [ :statements ]
end

def fx_coverage_start_date
Expand All @@ -71,6 +79,13 @@ def tab_content_for(tab)
when :holdings, :overview
# Accountable is responsible for implementing the partial in the correct folder
render "#{account.accountable_type.downcase.pluralize}/tabs/#{tab}", account: account
when :statements
render "accounts/show/statements",
account: account,
coverage: statement_coverage,
statements: statements,
reconciliation_statuses: reconciliation_statuses,
can_manage_statements: can_manage_statements
end
end
end
211 changes: 211 additions & 0 deletions app/controllers/account_statements_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
# frozen_string_literal: true

class AccountStatementsController < ApplicationController
before_action :set_statement, only: %i[show update destroy link unlink reject]
before_action :ensure_statement_manager!, only: %i[index create update destroy link unlink reject]

def index
accessible_account_ids = Current.user.accessible_accounts.select(:id)
account_statements = Current.family.account_statements
.with_attached_original_file
.includes(:account, :suggested_account)
.ordered
visible_storage_scope = Current.family.account_statements
.where(account_id: nil)
.or(Current.family.account_statements.where(account_id: accessible_account_ids))
linked_statement_scope = account_statements.with_account.where(account_id: accessible_account_ids)

@unmatched_pagy, @unmatched_statements = pagy(account_statements.unmatched, limit: safe_per_page, page_param: :unmatched_page)
@linked_pagy, @linked_statements = pagy(linked_statement_scope, limit: safe_per_page, page_param: :linked_page)
@total_storage_bytes = visible_storage_scope.sum(:byte_size)
@accounts = Current.user.accessible_accounts.visible.alphabetically
@breadcrumbs = [
[ t("breadcrumbs.home"), root_path ],
[ t("account_statements.index.title"), account_statements_path ]
]
render layout: "settings"
end

def show
@accounts = Current.user.accessible_accounts.visible.alphabetically
@can_manage_statement = @statement.manageable_by?(Current.user)
@reconciliation_checks = @statement.reconciliation_checks
@breadcrumbs = [
[ t("breadcrumbs.home"), root_path ],
[ t("account_statements.index.title"), account_statements_path ],
[ @statement.filename, nil ]
]
render layout: "settings"
end

def create
files = Array(statement_upload_params[:files]).reject(&:blank?).select { |file| file.respond_to?(:read) }
account = target_account

if files.empty?
redirect_back_or_to account_statements_path, alert: t("account_statements.create.no_files")
return
end

return if account && !require_account_permission!(account)

created = []
duplicates = []
validation_errors = []

files.each do |file|
prepared_upload = AccountStatement.prepare_upload!(file)
created << AccountStatement.create_from_prepared_upload!(family: Current.family, account: account, prepared_upload: prepared_upload)
rescue AccountStatement::InvalidUploadError
validation_errors << t("account_statements.create.invalid_file_type")
rescue AccountStatement::DuplicateUploadError => e
duplicates << e.statement
rescue ActiveRecord::RecordInvalid => e
validation_errors << e.record.errors.full_messages.to_sentence
end

redirect_to redirect_after_create(account, created.first || duplicates.first),
flash_for_upload(created:, duplicates:, validation_errors:)
end

def update
return if @statement.account && !require_account_permission!(@statement.account)

target = statement_account_id.present? ? Current.user.accessible_accounts.find(statement_account_id) : nil
return if target && !require_account_permission!(target)

attrs = statement_params.to_h
attrs[:account] = target if statement_account_id_provided?

@statement.assign_attributes(attrs)
@statement.assign_account_match if @statement.account.nil? && !@statement.rejected?

if @statement.save
redirect_to account_statement_path(@statement), notice: t("account_statements.update.success")
else
@accounts = Current.user.accessible_accounts.visible.alphabetically
@can_manage_statement = @statement.manageable_by?(Current.user)
@reconciliation_checks = @statement.reconciliation_checks
flash.now[:alert] = @statement.errors.full_messages.to_sentence
render :show, status: :unprocessable_entity, layout: "settings"
end
end

def link
return if @statement.account && !require_account_permission!(@statement.account)

account_id = params[:account_id].presence || @statement.suggested_account_id
if account_id.blank?
redirect_to account_statement_path(@statement), alert: t("account_statements.link.no_account")
return
end

account = Current.user.accessible_accounts.find(account_id)
return unless require_account_permission!(account)

@statement.link_to_account!(account)
Comment thread
JSONbored marked this conversation as resolved.
redirect_to post_link_path(@statement), notice: t("account_statements.link.success", account: account.name)
end

def unlink
return if @statement.account && !require_account_permission!(@statement.account)

@statement.unlink!
redirect_to account_statement_path(@statement), notice: t("account_statements.unlink.success")
end

def reject
return if @statement.account && !require_account_permission!(@statement.account)

@statement.reject_match!
redirect_to account_statements_path, notice: t("account_statements.reject.success")
end

def destroy
return if @statement.account && !require_account_permission!(@statement.account)

redirect_path = @statement.account ? account_path(@statement.account, tab: "statements") : account_statements_path
if @statement.destroy
redirect_to redirect_path, notice: t("account_statements.destroy.success")
else
redirect_back_or_to redirect_path, alert: t("account_statements.destroy.failure")
end
end

private

def set_statement
@statement = Current.family.account_statements
.with_attached_original_file
.includes(:account, :suggested_account)
.find(params[:id])

raise ActiveRecord::RecordNotFound unless @statement.viewable_by?(Current.user)
end

def ensure_statement_manager!
return if AccountStatement.statement_manager?(Current.user)

redirect_to accounts_path, alert: t("accounts.not_authorized")
end

def statement_upload_params
params.fetch(:account_statement, ActionController::Parameters.new).permit(files: [])
end

def statement_params
params.require(:account_statement).permit(
:institution_name_hint,
:account_name_hint,
:account_last4_hint,
:period_start_on,
:period_end_on,
:opening_balance,
:closing_balance,
:currency
)
end

def target_account
account_id = statement_account_id.presence
return nil if account_id.blank?

Current.user.accessible_accounts.find(account_id)
end

def statement_account_id
params.fetch(:account_statement, ActionController::Parameters.new)[:account_id]
end

def statement_account_id_provided?
params.fetch(:account_statement, ActionController::Parameters.new).key?(:account_id)
end

def redirect_after_create(account, statement = nil)
if account
account_path(account, tab: "statements")
elsif statement
account_statement_path(statement)
else
account_statements_path
end
end

def post_link_path(statement)
statement.account ? account_path(statement.account, tab: "statements") : account_statement_path(statement)
end

def flash_for_upload(created:, duplicates:, validation_errors: [])
alerts = []
alerts << t("account_statements.create.duplicates", count: duplicates.size) if duplicates.any?
alerts.concat(validation_errors.compact_blank)

if created.any?
flash = { notice: t("account_statements.create.success", count: created.size) }
flash[:alert] = alerts.to_sentence if alerts.any?
flash
else
{ alert: alerts.to_sentence }
end
end
end
10 changes: 10 additions & 0 deletions app/controllers/accounts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def show
@tab = params[:tab]
@q = params.fetch(:q, {}).permit(:search, status: [])
entries = @account.entries.where(excluded: false).search(@q).reverse_chronological
build_statement_tab_data

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
@pagy, @entries = pagy(
entries,
Expand Down Expand Up @@ -234,6 +235,15 @@ def visible_provider_items(items)
end
end

def build_statement_tab_data
@statement_coverage = AccountStatement::Coverage.for_year(@account, params[:statement_year])
@account_statements = @account.account_statements.with_attached_original_file.ordered.to_a
@statement_reconciliation_statuses = AccountStatement.reconciliation_statuses_for(@account_statements, account: @account)
permission = @account.permission_for(Current.user)
@can_manage_statements = AccountStatement.statement_manager?(Current.user) &&
permission.in?([ :owner, :full_control ])
end

# Builds sync stats maps for all provider types to avoid N+1 queries in views
def build_sync_stats_maps
# SimpleFIN sync stats
Expand Down
Loading
Loading