Files
sure/app/models/account_statement.rb
ghost e59235fdc5 feat(statements): add account statement vault (#1753)
* feat(statements): add account statement vault

Add web-only statement uploads, account linking, duplicate detection, and per-account coverage/reconciliation checks without mutating transactions. Extend ActiveStorage authorization and targeted tests for family/account scoping.

* fix(statements): return deleted account statements to inbox

Preserve linked statement records when an account is deleted by moving them back to the unmatched inbox, then expand coverage for upload validation, sanitized parser metadata, unavailable reconciliation, and missing-month coverage.

* fix(statements): harden vault upload review flows

Address review and security findings in the statement vault by preserving sanitized parser metadata, failing closed on orphaned statement blobs, avoiding account_id mass assignment permits, and adding regression coverage for link/delete edge cases.

* fix(statements): harden vault upload and access controls

* fix(statements): address vault hardening review

* fix(statements): address vault review feedback

Prioritize SHA-256 duplicate detection while preserving MD5 fallback for legacy rows.

Remove free-form account notes from statement matching, document direct account-destroy unlinking, and add year-selectable historical coverage with muted out-of-range months.

* fix(statements): harden vault review follow-ups

Clarify legacy MD5 checksum use, whitelist statement balance helper dispatch, and preserve sanitized parser metadata.

Hide statement management controls from read-only viewers while keeping server-side authorization unchanged.

* fix(statements): repair settings system coverage

Allow the changelog provider lookup in the self-hosting settings system test, include Statement Vault in settings navigation coverage, and align the feature title casing. Update the devcontainer so ActiveStorage and parallel system tests can run in the documented environment.

* fix(statements): move vault beside accounts

Place Statement Vault with account settings instead of between Imports and Exports. Keep settings footer ordering and system navigation coverage aligned, including the non-admin visibility guard.

* fix(statements): address vault review cleanup

Resolve CodeRabbit review feedback for statement upload validation, duplicate race handling, account statement matching semantics, metadata detection, ActiveStorage authorization tests, and small UI/style cleanups.

* fix(statements): address vault cleanup review

* fix(statements): deduplicate vault style helpers

* fix(statements): close vault review follow-ups

* fix(statements): refresh schema after upstream rebase

* fix(statements): process vault uploads sequentially

* fix(statements): close vault review follow-ups

* fix(statements): scope vault index to accessible accounts

* fix(statements): harden statement vault readiness

Squash the statement vault migration hardening into the feature migration, tighten Active Storage authorization edge cases, bound CSV metadata detection, and add real PDF fixture coverage for stored statements.

Validation: targeted statement/auth/controller/provider tests, full Rails suite, system tests, RuboCop, Biome, Brakeman, Zeitwerk, importmap audit, npm audit, ERB lint, CodeRabbit, and Codex Security all passed locally.

* fix(statements): close vault review follow-ups

Move statement unlinking to after account destroy commit, keep Kraken account creation on the shared crypto helper, and add statement metadata length limits with DB checks.

Validation: fresh devcontainer with fresh DB via db:prepare, focused account/statement/Kraken/Binance tests, RuboCop, Brakeman, Zeitwerk, git diff --check, CodeRabbit, and Codex Security passed before commit.

* fix(statements): address vault scan follow-ups

Move statement tab data setup out of the ERB partial, harden reconciliation labels and coverage initialization, and tighten statement schema constraints.

Validation: CodeRabbit and Codex Security reviewed the current PR diff; Rails focused tests, full Rails tests, system tests, RuboCop, Brakeman, Zeitwerk, ERB lint, npm lint, importmap audit, npm audit, and git diff --check passed.

* fix(statements): defer vault tab loading

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-13 21:05:11 +02:00

463 lines
15 KiB
Ruby

# frozen_string_literal: true
require "digest/md5"
require "digest/sha2"
require "stringio"
class AccountStatement < ApplicationRecord
include Monetizable
DuplicateUploadError = Class.new(StandardError) do
attr_reader :statement
def initialize(statement)
@statement = statement
super("Statement file has already been uploaded")
end
end
InvalidUploadError = Class.new(StandardError)
PreparedUpload = Data.define(:content, :filename, :content_type, :byte_size, :checksum, :content_sha256)
MAX_FILE_SIZE = 25.megabytes
READ_CHUNK_SIZE = 1.megabyte
ALLOWED_EXTENSION_CONTENT_TYPES = {
".pdf" => %w[application/pdf],
".csv" => %w[text/csv text/plain application/csv application/vnd.ms-excel],
".xlsx" => %w[application/vnd.openxmlformats-officedocument.spreadsheetml.sheet]
}.freeze
ALLOWED_CONTENT_TYPES = ALLOWED_EXTENSION_CONTENT_TYPES.values.flatten.uniq.freeze
ACCEPTED_FILE_EXTENSIONS = ALLOWED_EXTENSION_CONTENT_TYPES.keys.freeze
belongs_to :family
belongs_to :account, optional: true
belongs_to :suggested_account, class_name: "Account", optional: true
has_one_attached :original_file, dependent: :purge_later
enum :source, { manual_upload: "manual_upload" }, validate: true, default: "manual_upload"
enum :upload_status, { stored: "stored", failed: "failed" }, validate: true, default: "stored"
enum :review_status, { unmatched: "unmatched", linked: "linked", rejected: "rejected" }, validate: true, default: "unmatched", scopes: false
monetize :opening_balance, :closing_balance
before_validation :sync_file_metadata, if: -> { original_file.attached? }
before_validation :normalize_currency
before_validation :sync_review_status
validates :filename, :content_type, :checksum, presence: true
validates :byte_size, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: MAX_FILE_SIZE }
validates :content_type, inclusion: { in: ALLOWED_CONTENT_TYPES }
validates :content_sha256,
format: { with: /\A[0-9a-f]{64}\z/ },
uniqueness: { scope: :family_id, allow_nil: true, message: :duplicate_statement_file },
allow_nil: true
validates :parser_confidence, :match_confidence, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }, allow_nil: true
validate :account_belongs_to_family
validate :suggested_account_belongs_to_family
validate :period_order
validate :currency_is_valid
validate :filename_extension_matches_content_type
validate :original_file_attached
validate :original_file_constraints, if: -> { original_file.attached? }
scope :ordered, -> { order(created_at: :desc) }
scope :with_account, -> { where.not(account_id: nil) }
scope :unmatched, -> { where(account_id: nil).where(review_status: "unmatched") }
scope :for_month, ->(month) {
month_start = month.to_date.beginning_of_month
month_end = month_start.end_of_month
where("period_start_on <= ? AND period_end_on >= ?", month_end, month_start)
}
class << self
def statement_manager?(user)
user&.admin? || user&.member?
end
def create_from_upload!(family:, account:, file:)
prepared_upload = prepare_upload!(file)
create_from_prepared_upload!(family: family, account: account, prepared_upload: prepared_upload)
end
def create_from_prepared_upload!(family:, account:, prepared_upload:)
statement = nil
duplicate = duplicate_for(family, prepared_upload)
raise DuplicateUploadError, duplicate if duplicate
statement = family.account_statements.build(
account: account,
filename: prepared_upload.filename,
content_type: prepared_upload.content_type,
byte_size: prepared_upload.byte_size,
checksum: prepared_upload.checksum,
content_sha256: prepared_upload.content_sha256,
source: :manual_upload,
upload_status: :stored,
review_status: account.present? ? :linked : :unmatched,
currency: account&.currency || family.currency
)
statement.original_file.attach(
io: StringIO.new(prepared_upload.content),
filename: prepared_upload.filename,
content_type: prepared_upload.content_type
)
MetadataDetector.new(statement, content: prepared_upload.content).apply
statement.assign_account_match unless account.present?
statement.save!
statement
rescue ActiveRecord::RecordNotUnique
duplicate = duplicate_for(family, prepared_upload)
purge_original_file(statement)
if duplicate
raise DuplicateUploadError, duplicate
end
raise
rescue StandardError
purge_original_file(statement)
raise
end
def reconciliation_statuses_for(statements, account:)
statement_list = statements.to_a
balance_lookup = balance_lookup_for(account, statement_list)
statement_list.to_h do |statement|
[ statement.id, statement.reconciliation_status(balance_lookup: balance_lookup) ]
end
end
def prepare_upload!(file)
filename = file.original_filename.to_s
content = read_upload_content!(file)
byte_size = content.bytesize
raise InvalidUploadError if byte_size.zero?
content_type = detected_content_type(content:, filename:, declared_content_type: file.content_type)
raise InvalidUploadError unless allowed_upload?(filename:, content_type:)
raise InvalidUploadError if content_type == "application/pdf" && !valid_pdf_content?(content)
PreparedUpload.new(
content: content,
filename: filename,
content_type: content_type,
byte_size: byte_size,
checksum: Digest::MD5.base64digest(content),
content_sha256: Digest::SHA256.hexdigest(content)
)
end
def detected_content_type(content:, filename:, declared_content_type:)
Marcel::MimeType.for(
StringIO.new(content),
name: filename,
declared_type: declared_content_type.presence
)
end
def allowed_upload?(filename:, content_type:)
allowed_content_types_for_filename(filename).include?(content_type)
end
def allowed_content_types_for_filename(filename)
ALLOWED_EXTENSION_CONTENT_TYPES.fetch(File.extname(filename.to_s).downcase, [])
end
def valid_pdf_content?(content)
content.start_with?("%PDF-")
end
def purge_original_file(statement)
return unless statement&.original_file&.attached?
statement.original_file.purge
rescue StandardError => e
Rails.logger.warn("AccountStatement staged blob cleanup failed: #{e.class}: #{e.message}")
end
def balance_lookup_for(account, statements)
currencies = statements.map(&:statement_currency).compact.uniq
dates = statements.flat_map { |statement| [ statement.period_start_on, statement.period_end_on ] }.compact.uniq
balances = if currencies.any? && dates.any?
account.balances.where(currency: currencies, date: dates).to_a
else
[]
end
balances_by_key = balances.index_by { |balance| [ balance.date, balance.currency ] }
->(date, currency) { balances_by_key[[ date, currency ]] }
end
def read_upload_content!(file)
declared_size = declared_upload_size(file)
raise InvalidUploadError if declared_size.present? && declared_size > MAX_FILE_SIZE
content = +"".b
loop do
chunk = file.read(READ_CHUNK_SIZE)
break if chunk.nil? || chunk.empty?
content << chunk
raise InvalidUploadError if content.bytesize > MAX_FILE_SIZE
end
file.rewind if file.respond_to?(:rewind)
content
end
def declared_upload_size(file)
if file.respond_to?(:size)
file.size
elsif file.respond_to?(:length)
file.length
end
end
def duplicate_for(family, prepared_upload)
scope = family.account_statements
sha_duplicate = scope.find_by(content_sha256: prepared_upload.content_sha256) if prepared_upload.content_sha256.present?
return sha_duplicate if sha_duplicate
# Active Storage's MD5 checksum is retained only to catch legacy rows that predate content_sha256.
legacy_scope = prepared_upload.content_sha256.present? ? scope.where(content_sha256: nil) : scope
legacy_scope.find_by(checksum: prepared_upload.checksum)
end
end
def viewable_by?(user)
return false unless user&.family_id == family_id
account.present? ? account.shared_with?(user) : self.class.statement_manager?(user)
end
def manageable_by?(user)
return false unless user&.family_id == family_id
return self.class.statement_manager?(user) if account.blank?
account.permission_for(user).in?([ :owner, :full_control ]) && self.class.statement_manager?(user)
end
def link_to_account!(target_account, confidence: 1.0)
update!(
account: target_account,
suggested_account: nil,
match_confidence: confidence,
review_status: :linked,
currency: currency.presence || target_account.currency
)
end
def unlink!
transaction do
update!(
account: nil,
review_status: :unmatched,
match_confidence: nil
)
assign_account_match
save!
end
end
def reject_match!
update!(
suggested_account: nil,
match_confidence: nil,
review_status: :rejected
)
end
def assign_account_match
match = AccountMatcher.new(self).best_match
self.suggested_account = match&.account
self.match_confidence = match&.confidence
clear_invalid_suggested_account
end
def covered_months
return [] unless period_start_on.present? && period_end_on.present?
current = period_start_on.beginning_of_month
last = period_end_on.beginning_of_month
months = []
while current <= last
months << current
current = current.next_month
end
months
end
def covers_month?(month)
covered_months.include?(month.to_date.beginning_of_month)
end
def reconciliation_status(balance_lookup: nil)
checks = reconciliation_checks(balance_lookup: balance_lookup)
return "unavailable" if checks.empty?
checks.any? { |check| check[:status] == "mismatched" } ? "mismatched" : "matched"
end
def reconciliation_mismatched?(balance_lookup: nil)
reconciliation_status(balance_lookup: balance_lookup) == "mismatched"
end
def reconciliation_checks(balance_lookup: nil)
return [] unless account.present? && period_start_on.present? && period_end_on.present?
checks = []
opening_balance_record = balance_record_for(period_start_on, statement_currency, balance_lookup)
closing_balance_record = balance_record_for(period_end_on, statement_currency, balance_lookup)
if opening_balance.present? && opening_balance_record.present?
checks << reconciliation_check(
key: "opening_balance",
statement_amount: opening_balance,
ledger_amount: opening_balance_record.start_balance
)
end
if closing_balance.present? && closing_balance_record.present?
checks << reconciliation_check(
key: "closing_balance",
statement_amount: closing_balance,
ledger_amount: closing_balance_record.end_balance
)
end
if opening_balance.present? && closing_balance.present? && opening_balance_record.present? && closing_balance_record.present?
checks << reconciliation_check(
key: "period_movement",
statement_amount: closing_balance - opening_balance,
ledger_amount: closing_balance_record.end_balance - opening_balance_record.start_balance
)
end
checks
end
def statement_currency
currency.presence || account&.currency || family.currency
end
def pdf?
content_type.in?(ALLOWED_EXTENSION_CONTENT_TYPES[".pdf"])
end
def csv?
content_type.in?(ALLOWED_EXTENSION_CONTENT_TYPES[".csv"])
end
def xlsx?
content_type.in?(ALLOWED_EXTENSION_CONTENT_TYPES[".xlsx"])
end
private
def reconciliation_check(key:, statement_amount:, ledger_amount:)
difference = statement_amount.to_d - ledger_amount.to_d
{
key: key,
statement_amount: statement_amount.to_d,
ledger_amount: ledger_amount.to_d,
difference: difference,
status: difference.abs <= 0.01.to_d ? "matched" : "mismatched"
}
end
def balance_record_for(date, currency, balance_lookup)
return balance_lookup.call(date, currency) if balance_lookup
account.balances.find_by(date: date, currency: currency)
end
def sync_file_metadata
blob = original_file.blob
self.filename ||= blob.filename.to_s
self.content_type ||= blob.content_type
self.byte_size ||= blob.byte_size
self.checksum ||= blob.checksum
end
def normalize_currency
self.currency = currency.to_s.upcase.presence if currency.present?
end
def sync_review_status
return if rejected?
self.review_status = "linked" if account.present? && !linked?
self.review_status = "unmatched" if account.blank? && linked?
end
def account_belongs_to_family
return if account.nil?
return if account.family_id == family_id
errors.add(:account, :invalid)
end
def suggested_account_belongs_to_family
return if suggested_account_valid_for_family?
errors.add(:suggested_account, :invalid)
end
def clear_invalid_suggested_account
return if suggested_account_valid_for_family?
self.suggested_account = nil
self.match_confidence = nil
end
def suggested_account_valid_for_family?
suggested_account.nil? || suggested_account.family_id == family_id
end
def period_order
return if period_start_on.blank? || period_end_on.blank?
return if period_start_on <= period_end_on
errors.add(:period_end_on, :on_or_after_start)
end
def currency_is_valid
return if currency.blank?
Money::Currency.new(currency)
rescue Money::Currency::UnknownCurrencyError, ArgumentError
errors.add(:currency, :invalid)
end
def filename_extension_matches_content_type
return if filename.blank? || content_type.blank?
return if self.class.allowed_upload?(filename: filename, content_type: content_type)
errors.add(:content_type, :invalid)
end
def original_file_constraints
if original_file.byte_size.zero?
errors.add(:original_file, :blank)
elsif original_file.byte_size > MAX_FILE_SIZE
errors.add(:original_file, :too_large, max_mb: MAX_FILE_SIZE / 1.megabyte)
end
unless self.class.allowed_upload?(filename: original_file.filename.to_s, content_type: original_file.content_type)
errors.add(:original_file, :invalid_format, file_format: original_file.content_type)
end
end
def original_file_attached
errors.add(:original_file, :blank) unless original_file.attached?
end
end