-
Notifications
You must be signed in to change notification settings - Fork 28
feat(statements): add account statement vault #1753
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jjmata
merged 24 commits into
we-promise:main
from
JSONbored:codex/feat-account-statement-vault-recreated
May 13, 2026
Merged
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 547c4c5
fix(statements): return deleted account statements to inbox
JSONbored 392a950
fix(statements): harden vault upload review flows
JSONbored c7976ed
fix(statements): harden vault upload and access controls
JSONbored f727dd4
fix(statements): address vault hardening review
JSONbored 1c230d7
fix(statements): address vault review feedback
JSONbored 98ad933
fix(statements): harden vault review follow-ups
JSONbored b4ef98e
fix(statements): repair settings system coverage
JSONbored 2c549cd
fix(statements): move vault beside accounts
JSONbored 116fab2
fix(statements): address vault review cleanup
JSONbored c530e36
fix(statements): address vault cleanup review
JSONbored 9d98d48
fix(statements): deduplicate vault style helpers
JSONbored e1f4965
fix(statements): close vault review follow-ups
JSONbored e9321da
fix(statements): refresh schema after upstream rebase
JSONbored f020a4d
fix(statements): process vault uploads sequentially
JSONbored 4cbcf36
fix(statements): close vault review follow-ups
JSONbored 7b95bfa
fix(statements): scope vault index to accessible accounts
JSONbored a508ef6
fix(statements): harden statement vault readiness
JSONbored b27d3bd
Merge branch 'main' into codex/feat-account-statement-vault-recreated
JSONbored 0b049f6
fix(statements): close vault review follow-ups
JSONbored 0f0d8cc
fix(statements): address vault scan follow-ups
JSONbored 4107c6f
Merge branch 'main' into codex/feat-account-statement-vault-recreated
JSONbored 0e4be85
fix(statements): defer vault tab loading
JSONbored 85431f4
Merge branch 'main' into codex/feat-account-statement-vault-recreated
jjmata File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.