mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 13:04:56 +00:00
* 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>
463 lines
15 KiB
Ruby
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
|