diff --git a/app/controllers/account_statements_controller.rb b/app/controllers/account_statements_controller.rb
index 27793c4b6..3c3bd992a 100644
--- a/app/controllers/account_statements_controller.rb
+++ b/app/controllers/account_statements_controller.rb
@@ -1,8 +1,8 @@
# 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]
+ before_action :set_statement, only: %i[show update destroy extract link unlink reject]
+ before_action :ensure_statement_manager!, only: %i[index create update destroy extract link unlink reject]
def index
accessible_account_ids = Current.user.accessible_accounts.select(:id)
@@ -30,6 +30,7 @@ def show
@accounts = Current.user.accessible_accounts.visible.alphabetically
@can_manage_statement = @statement.manageable_by?(Current.user)
@reconciliation_checks = @statement.reconciliation_checks
+ @latest_pdf_import = @statement.latest_reusable_pdf_import if @statement.pdf?
@breadcrumbs = [
[ t("breadcrumbs.home"), root_path ],
[ t("account_statements.index.title"), account_statements_path ],
@@ -41,6 +42,7 @@ def show
def create
files = Array(statement_upload_params[:files]).reject(&:blank?).select { |file| file.respond_to?(:read) }
account = target_account
+ return if performed?
if files.empty?
redirect_back_or_to account_statements_path, alert: t("account_statements.create.no_files")
@@ -56,22 +58,24 @@ def create
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::InvalidUploadError => e
+ validation_errors << invalid_upload_message(e)
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),
+ redirect_statement = created.first || duplicates.find { |statement| statement.viewable_by?(Current.user) }
+ redirect_to redirect_after_create(account, redirect_statement),
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
+ target = statement_account_id.present? ? target_account : nil
+ return if performed?
return if target && !require_account_permission!(target)
attrs = statement_params.to_h
@@ -100,7 +104,11 @@ def link
return
end
- account = Current.user.accessible_accounts.find(account_id)
+ account = Current.user.accessible_accounts.find_by(id: account_id)
+ unless account
+ redirect_to account_statement_path(@statement), alert: t("accounts.not_authorized")
+ return
+ end
return unless require_account_permission!(account)
@statement.link_to_account!(account)
@@ -116,6 +124,7 @@ def unlink
def reject
return if @statement.account && !require_account_permission!(@statement.account)
+ return redirect_to(account_statement_path(@statement), alert: t("account_statements.reject.linked")) if @statement.account.present?
@statement.reject_match!
redirect_to account_statements_path, notice: t("account_statements.reject.success")
@@ -132,6 +141,23 @@ def destroy
end
end
+ def extract
+ unless @statement.manageable_by?(Current.user)
+ redirect_to account_statement_path(@statement), alert: t("accounts.not_authorized")
+ return
+ end
+
+ unless @statement.pdf?
+ redirect_to account_statement_path(@statement), alert: t("account_statements.extract.not_pdf")
+ return
+ end
+
+ pdf_import = PdfImport.create_from_statement!(statement: @statement)
+ pdf_import.process_with_ai_later
+
+ redirect_to import_path(pdf_import), notice: t("account_statements.extract.started")
+ end
+
private
def set_statement
@@ -146,7 +172,7 @@ def set_statement
def ensure_statement_manager!
return if AccountStatement.statement_manager?(Current.user)
- redirect_to accounts_path, alert: t("accounts.not_authorized")
+ redirect_back_or_to accounts_path, alert: t("accounts.not_authorized")
end
def statement_upload_params
@@ -170,7 +196,11 @@ def target_account
account_id = statement_account_id.presence
return nil if account_id.blank?
- Current.user.accessible_accounts.find(account_id)
+ account = Current.user.accessible_accounts.find_by(id: account_id)
+ return account if account
+
+ redirect_back_or_to account_statements_path, alert: t("accounts.not_authorized")
+ nil
end
def statement_account_id
@@ -191,6 +221,14 @@ def redirect_after_create(account, statement = nil)
end
end
+ def invalid_upload_message(error)
+ reason = error.respond_to?(:reason) ? error.reason : nil
+ key = reason.in?(%i[oversize unsupported_type empty invalid_pdf_header]) ? reason : :invalid_file_type
+
+ t("account_statements.create.upload_errors.#{key}",
+ default: t("account_statements.create.invalid_file_type"))
+ end
+
def post_link_path(statement)
statement.account ? account_path(statement.account, tab: "statements") : account_statement_path(statement)
end
diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb
index 4a3cc0492..8ba5a2e06 100644
--- a/app/controllers/imports_controller.rb
+++ b/app/controllers/imports_controller.rb
@@ -2,9 +2,9 @@ class ImportsController < ApplicationController
include SettingsHelper
before_action :set_import, only: %i[show update publish destroy revert apply_template]
+ before_action :require_statement_import_permission!, only: %i[update publish destroy revert apply_template]
def update
- # Handle both pdf_import[account_id] and import[account_id] param formats
account_id = params.dig(:pdf_import, :account_id) || params.dig(:import, :account_id)
if account_id.present?
@@ -13,7 +13,9 @@ def update
redirect_back_or_to import_path(@import), alert: t("imports.update.invalid_account", default: "Account not found.")
return
end
- @import.update!(account: account)
+ return if @import.account_statement.present? && !require_account_permission!(account)
+
+ @import.assign_account!(account)
end
redirect_to import_path(@import), notice: t("imports.update.account_saved", default: "Account saved.")
@@ -54,12 +56,7 @@ def create
return
end
- # Handle PDF file uploads - process with AI
if file.present? && Import::ALLOWED_PDF_MIME_TYPES.include?(file.content_type)
- unless valid_pdf_file?(file)
- redirect_to new_import_path, alert: t("imports.create.invalid_pdf")
- return
- end
create_pdf_import(file)
return
end
@@ -87,8 +84,6 @@ def create
return
end
- # Stream reading is not fully applicable here as we store the raw string in the DB,
- # but we have validated size beforehand to prevent memory exhaustion from massive files.
import.update!(raw_file_str: file.read)
redirect_to import_configuration_path(import), notice: t("imports.create.csv_uploaded")
@@ -132,24 +127,41 @@ def destroy
private
def set_import
- @import = Current.family.imports.includes(:account).find(params[:id])
+ @import = Current.family.imports.includes(:account, :account_statement).find(params[:id])
+ raise ActiveRecord::RecordNotFound if @import.account_statement.present? && !@import.account_statement.viewable_by?(Current.user)
end
def import_params
params.require(:import).permit(:import_file)
end
+ def require_statement_import_permission!
+ return unless @import.account_statement.present?
+ return if @import.account_statement.manageable_by?(Current.user)
+
+ redirect_target = @import.account || @import.account_statement
+ redirect_back_or_to redirect_target, alert: t("accounts.not_authorized")
+ end
+
def create_pdf_import(file)
+ unless AccountStatement.statement_manager?(Current.user)
+ redirect_to new_import_path, alert: t("accounts.not_authorized")
+ return
+ end
+
if file.size > Import::MAX_PDF_SIZE
redirect_to new_import_path, alert: t("imports.create.pdf_too_large", max_size: Import::MAX_PDF_SIZE / 1.megabyte)
return
end
- pdf_import = Current.family.imports.create!(type: "PdfImport")
- pdf_import.pdf_file.attach(file)
+ pdf_import = PdfImport.create_from_upload!(family: Current.family, file: file, user: Current.user)
pdf_import.process_with_ai_later
redirect_to import_path(pdf_import), notice: t("imports.create.pdf_processing")
+ rescue AccountStatement::DuplicateUploadError
+ redirect_to new_import_path, alert: t("imports.create.duplicate_pdf_unavailable")
+ rescue AccountStatement::InvalidUploadError
+ redirect_to new_import_path, alert: t("imports.create.invalid_pdf")
end
def create_document_import(file)
@@ -174,11 +186,6 @@ def create_document_import(file)
end
if ext == ".pdf"
- unless valid_pdf_file?(file)
- redirect_to new_import_path, alert: t("imports.create.invalid_pdf")
- return
- end
-
create_pdf_import(file)
return
end
@@ -239,10 +246,4 @@ def create_sure_import(file)
redirect_to import_path(import), notice: t("imports.create.ndjson_uploaded")
end
-
- def valid_pdf_file?(file)
- header = file.read(5)
- file.rewind
- header&.start_with?("%PDF-")
- end
end
diff --git a/app/jobs/process_pdf_job.rb b/app/jobs/process_pdf_job.rb
index 1ed48cf49..0145b1541 100644
--- a/app/jobs/process_pdf_job.rb
+++ b/app/jobs/process_pdf_job.rb
@@ -1,11 +1,13 @@
class ProcessPdfJob < ApplicationJob
+ PROCESSING_CLAIM_TTL = 30.minutes
+
queue_as :medium_priority
def perform(pdf_import)
return unless pdf_import.is_a?(PdfImport)
- return unless pdf_import.pdf_uploaded?
+ return reset_processing_claim(pdf_import) unless pdf_import.pdf_uploaded?
return if pdf_import.status == "complete"
- return if pdf_import.ai_processed? && (!pdf_import.statement_with_transactions? || pdf_import.rows_count > 0)
+ return reset_processing_claim(pdf_import) if pdf_import.ai_processed? && (!pdf_import.statement_with_transactions? || pdf_import.rows_count > 0)
pdf_import.update!(status: :importing)
@@ -62,7 +64,7 @@ def sanitize_error_message(error)
end
def upload_to_vector_store(pdf_import, document_type:)
- filename = pdf_import.pdf_file.filename.to_s
+ filename = pdf_import.pdf_filename
file_content = pdf_import.pdf_file_content
family_document = pdf_import.family.upload_document(
@@ -85,4 +87,16 @@ def resolve_document_type(pdf_import, process_result)
def statement_with_transactions?(document_type)
document_type.in?(%w[bank_statement credit_card_statement])
end
+
+ def reset_processing_claim(pdf_import)
+ pdf_import.with_lock do
+ if pdf_import.importing? && processing_claim_stale?(pdf_import)
+ pdf_import.update!(status: :pending)
+ end
+ end
+ end
+
+ def processing_claim_stale?(pdf_import)
+ pdf_import.updated_at <= PROCESSING_CLAIM_TTL.ago
+ end
end
diff --git a/app/models/account_statement.rb b/app/models/account_statement.rb
index 07cc80c86..4befd4664 100644
--- a/app/models/account_statement.rb
+++ b/app/models/account_statement.rb
@@ -15,7 +15,15 @@ def initialize(statement)
super("Statement file has already been uploaded")
end
end
- InvalidUploadError = Class.new(StandardError)
+ InvalidUploadError = Class.new(StandardError) do
+ attr_reader :reason
+
+ def initialize(reason = :invalid_file_type)
+ @reason = reason
+ super("Invalid statement upload: #{reason}")
+ end
+ end
+ LinkedStatementRejectionError = Class.new(StandardError)
PreparedUpload = Data.define(:content, :filename, :content_type, :byte_size, :checksum, :content_sha256)
@@ -33,6 +41,7 @@ def initialize(statement)
belongs_to :account, optional: true
belongs_to :suggested_account, class_name: "Account", optional: true
+ has_many :pdf_imports, -> { where(type: "PdfImport").ordered }, class_name: "PdfImport", dependent: :nullify
has_one_attached :original_file, dependent: :purge_later
enum :source, { manual_upload: "manual_upload" }, validate: true, default: "manual_upload"
@@ -82,6 +91,7 @@ def create_from_upload!(family:, account:, file:)
def create_from_prepared_upload!(family:, account:, prepared_upload:)
statement = nil
+ saved = false
duplicate = duplicate_for(family, prepared_upload)
raise DuplicateUploadError, duplicate if duplicate
@@ -107,19 +117,15 @@ def create_from_prepared_upload!(family:, account:, prepared_upload:)
MetadataDetector.new(statement, content: prepared_upload.content).apply
statement.assign_account_match unless account.present?
statement.save!
+ saved = true
statement
rescue ActiveRecord::RecordNotUnique
duplicate = duplicate_for(family, prepared_upload)
- purge_original_file(statement)
-
- if duplicate
- raise DuplicateUploadError, duplicate
- end
+ raise DuplicateUploadError, duplicate if duplicate
raise
- rescue StandardError
- purge_original_file(statement)
- raise
+ ensure
+ purge_original_file(statement) if statement && !saved
end
def reconciliation_statuses_for(statements, account:)
@@ -135,11 +141,11 @@ 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?
+ raise InvalidUploadError, :empty 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)
+ raise InvalidUploadError, :unsupported_type unless allowed_upload?(filename:, content_type:)
+ raise InvalidUploadError, :invalid_pdf_header if content_type == "application/pdf" && !valid_pdf_content?(content)
PreparedUpload.new(
content: content,
@@ -194,7 +200,7 @@ def balance_lookup_for(account, statements)
def read_upload_content!(file)
declared_size = declared_upload_size(file)
- raise InvalidUploadError if declared_size.present? && declared_size > MAX_FILE_SIZE
+ raise InvalidUploadError, :oversize if declared_size.present? && declared_size > MAX_FILE_SIZE
content = +"".b
loop do
@@ -202,7 +208,7 @@ def read_upload_content!(file)
break if chunk.nil? || chunk.empty?
content << chunk
- raise InvalidUploadError if content.bytesize > MAX_FILE_SIZE
+ raise InvalidUploadError, :oversize if content.bytesize > MAX_FILE_SIZE
end
file.rewind if file.respond_to?(:rewind)
@@ -254,7 +260,7 @@ def link_to_account!(target_account, confidence: 1.0)
def unlink!
transaction do
- update!(
+ assign_attributes(
account: nil,
review_status: :unmatched,
match_confidence: nil
@@ -265,6 +271,8 @@ def unlink!
end
def reject_match!
+ raise LinkedStatementRejectionError, "Linked statements cannot reject a suggested match" if account.present?
+
update!(
suggested_account: nil,
match_confidence: nil,
@@ -360,6 +368,10 @@ def xlsx?
content_type.in?(ALLOWED_EXTENSION_CONTENT_TYPES[".xlsx"])
end
+ def latest_reusable_pdf_import
+ pdf_imports.where.not(status: :failed).order(created_at: :desc).first
+ end
+
private
def reconciliation_check(key:, statement_amount:, ledger_amount:)
@@ -388,7 +400,7 @@ def sync_file_metadata
end
def normalize_currency
- self.currency = currency.to_s.upcase.presence if currency.present?
+ self.currency = currency.to_s.strip.upcase.presence if currency.present?
end
def sync_review_status
diff --git a/app/models/account_statement/account_matcher.rb b/app/models/account_statement/account_matcher.rb
index 6e8c5c1c2..6ce84c893 100644
--- a/app/models/account_statement/account_matcher.rb
+++ b/app/models/account_statement/account_matcher.rb
@@ -10,7 +10,7 @@ def initialize(statement)
end
def best_match
- candidates = statement.family.accounts.visible.to_a.filter_map do |account|
+ candidates = statement.family.accounts.visible.includes(:account_providers).to_a.filter_map do |account|
confidence = confidence_for(account)
next if confidence < 0.35
diff --git a/app/models/account_statement/coverage.rb b/app/models/account_statement/coverage.rb
index 225df183c..ef4c251e4 100644
--- a/app/models/account_statement/coverage.rb
+++ b/app/models/account_statement/coverage.rb
@@ -70,8 +70,8 @@ def default_expected_start_month(account, fallback_end_month: default_expected_e
candidates = [
account.entries.minimum(:date),
account.balances.minimum(:date),
- account.account_statements.where.not(period_start_on: nil).minimum(:period_start_on),
- account.family.account_statements.unmatched.where(suggested_account: account).where.not(period_start_on: nil).minimum(:period_start_on)
+ account.account_statements.where.not(period_start_on: nil).where.not(period_end_on: nil).minimum(:period_start_on),
+ account.family.account_statements.unmatched.where(suggested_account: account).where.not(period_start_on: nil).where.not(period_end_on: nil).minimum(:period_start_on)
].compact
start_month = (candidates.min || fallback_end_month.advance(months: -11)).to_date.beginning_of_month
diff --git a/app/models/account_statement/metadata_detector.rb b/app/models/account_statement/metadata_detector.rb
index e8079d5b8..05c488b8e 100644
--- a/app/models/account_statement/metadata_detector.rb
+++ b/app/models/account_statement/metadata_detector.rb
@@ -115,7 +115,6 @@ def detect_from_filename
if (meaningful_hint = meaningful_filename_hint(hint))
statement.institution_name_hint ||= meaningful_hint
- statement.account_name_hint ||= meaningful_hint
detected = true
end
diff --git a/app/models/import.rb b/app/models/import.rb
index 2f1256ff4..fd1b63ce3 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -29,10 +29,15 @@ def self.max_csv_size
MAX_CSV_SIZE
end
+ def assign_account!(account)
+ update!(account: account)
+ end
+
AMOUNT_TYPE_STRATEGIES = %w[signed_amount custom_column].freeze
belongs_to :family
belongs_to :account, optional: true
+ belongs_to :account_statement, optional: true
before_validation :set_default_number_format
before_validation :ensure_utf8_encoding
diff --git a/app/models/pdf_import.rb b/app/models/pdf_import.rb
index f610e6b0f..b80725793 100644
--- a/app/models/pdf_import.rb
+++ b/app/models/pdf_import.rb
@@ -2,6 +2,46 @@ class PdfImport < Import
has_one_attached :pdf_file, dependent: :purge_later
validates :document_type, inclusion: { in: DOCUMENT_TYPES }, allow_nil: true
+ validate :account_statement_belongs_to_family
+ validate :account_statement_is_pdf
+
+ class << self
+ def create_from_upload!(family:, file:, user:)
+ prepared_upload = AccountStatement.prepare_upload!(file)
+ statement = AccountStatement.create_from_prepared_upload!(
+ family: family,
+ account: nil,
+ prepared_upload: prepared_upload
+ )
+
+ create_from_statement!(statement: statement)
+ rescue AccountStatement::DuplicateUploadError => e
+ raise unless e.statement.viewable_by?(user)
+
+ create_from_statement!(statement: e.statement)
+ end
+
+ def create_from_statement!(statement:)
+ reusable_import = statement.latest_reusable_pdf_import
+ return reusable_import if reusable_pdf_import_current?(reusable_import, statement)
+
+ create!(
+ family: statement.family,
+ account: statement.account,
+ account_statement: statement,
+ date_format: statement.family.date_format,
+ status: :pending
+ )
+ end
+
+ private
+
+ def reusable_pdf_import_current?(pdf_import, statement)
+ pdf_import.present? &&
+ pdf_import.account_id == statement.account_id &&
+ pdf_import.date_format == statement.family.date_format
+ end
+ end
def import!
raise "Account required for PDF import" unless account.present?
@@ -31,8 +71,19 @@ def import!
end
end
+ def assign_account!(account)
+ transaction do
+ update!(account: account)
+ statement = account_statement
+ if statement.present? && account.present?
+ statement.lock!
+ statement.link_to_account!(account) if statement.account_id != account.id
+ end
+ end
+ end
+
def pdf_uploaded?
- pdf_file.attached?
+ source_pdf_attached?
end
def ai_processed?
@@ -40,7 +91,25 @@ def ai_processed?
end
def process_with_ai_later
- ProcessPdfJob.perform_later(self)
+ should_enqueue = with_lock do
+ if pending? && !ai_processed? && rows_count.zero?
+ update!(status: :importing)
+ true
+ else
+ false
+ end
+ end
+
+ return false unless should_enqueue
+
+ begin
+ ProcessPdfJob.perform_later(self)
+ true
+ rescue StandardError => e
+ Rails.logger.error("Failed to enqueue PDF processing for import #{id}: #{e.class.name} - #{e.message}")
+ reload.with_lock { update!(status: :pending) }
+ false
+ end
end
def process_with_ai
@@ -172,9 +241,29 @@ def requires_csv_workflow?
end
def pdf_file_content
- return nil unless pdf_file.attached?
+ return @pdf_file_content if defined?(@pdf_file_content)
- pdf_file.download
+ @pdf_file_content = if statement_backed?
+ account_statement.original_file.download
+ elsif pdf_file.attached?
+ pdf_file.download
+ end
+ end
+
+ def pdf_filename
+ if statement_backed?
+ account_statement.filename
+ elsif pdf_file.attached?
+ pdf_file.filename.to_s
+ end
+ end
+
+ def statement_backed?
+ account_statement&.original_file&.attached?
+ end
+
+ def legacy_pdf_file?
+ pdf_file.attached?
end
def required_column_keys
@@ -199,4 +288,22 @@ def format_date_for_import(date_str)
rescue ArgumentError
date_str.to_s
end
+
+ def source_pdf_attached?
+ statement_backed? || legacy_pdf_file?
+ end
+
+ def account_statement_belongs_to_family
+ return if account_statement.blank?
+ return if account_statement.family_id == family_id
+
+ errors.add(:account_statement, :invalid)
+ end
+
+ def account_statement_is_pdf
+ return if account_statement.blank?
+ return if account_statement.pdf?
+
+ errors.add(:account_statement, :invalid)
+ end
end
diff --git a/app/views/account_statements/show.html.erb b/app/views/account_statements/show.html.erb
index e320fc79a..69ae21674 100644
--- a/app/views/account_statements/show.html.erb
+++ b/app/views/account_statements/show.html.erb
@@ -138,6 +138,32 @@
<% end %>
+
+
<%= t("account_statements.extract.title") %>
+
+ <% if @statement.pdf? %>
+
+ <% if @latest_pdf_import.present? %>
+ <%= link_to import_path(@latest_pdf_import), class: "inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-primary-hover" do %>
+ <%= icon("file-search", size: "sm") %>
+ <%= t("account_statements.extract.latest_import") %>
+ <% end %>
+ <% end %>
+
+ <% if @can_manage_statement %>
+ <%= button_to extract_account_statement_path(@statement),
+ method: :post,
+ class: "inline-flex items-center gap-2 text-sm font-medium text-primary" do %>
+ <%= icon("scan-text", size: "sm") %>
+ <%= t("account_statements.extract.start") %>
+ <% end %>
+ <% end %>
+
+ <% else %>
+
<%= t("account_statements.extract.unavailable") %>
+ <% end %>
+
+
<%= t(".reconciliation_title") %>
diff --git a/app/views/accounts/show/_menu.html.erb b/app/views/accounts/show/_menu.html.erb
index cb577369e..99d5a29fe 100644
--- a/app/views/accounts/show/_menu.html.erb
+++ b/app/views/accounts/show/_menu.html.erb
@@ -6,7 +6,9 @@
<% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %>
<% end %>
<% menu.with_item(variant: "link", text: "Sharing", href: account_sharing_path(account), icon: "users", data: { turbo_frame: :modal }) %>
- <% menu.with_item(variant: "link", text: t(".statements"), href: account_path(account, tab: "statements"), icon: "archive") %>
+ <% if permission.in?([ :owner, :full_control ]) && AccountStatement.statement_manager?(Current.user) %>
+ <% menu.with_item(variant: "link", text: t(".statements"), href: account_path(account, tab: "statements"), icon: "archive") %>
+ <% end %>
<% if permission.in?([ :owner, :full_control ]) %>
<% if account.supports_trades? %>
diff --git a/app/views/imports/_pdf_import.html.erb b/app/views/imports/_pdf_import.html.erb
index 068d55da9..097087a7b 100644
--- a/app/views/imports/_pdf_import.html.erb
+++ b/app/views/imports/_pdf_import.html.erb
@@ -14,16 +14,25 @@
+ <% if import.account_statement.present? %>
+
+
<%= t("imports.pdf_import.source_statement") %>
+
+ <%= link_to import.account_statement.filename, account_statement_path(import.account_statement), class: "text-primary hover:text-primary-hover" %>
+
+
+ <% end %>
+
<%= t("imports.pdf_import.document_type_label") %>
-
+
<%= t("imports.document_types.#{import.document_type}", default: import.document_type&.humanize || t("imports.pdf_import.unknown_document_type", default: "Unknown")) %>
<%= t("imports.pdf_import.transactions_extracted", default: "Transactions Extracted") %>
-
+
<%= t("imports.pdf_import.transactions_extracted_count", count: import.rows_count, default: "%{count} transactions") %>
@@ -106,16 +115,25 @@
+ <% if import.account_statement.present? %>
+
+
<%= t("imports.pdf_import.source_statement") %>
+
+ <%= link_to import.account_statement.filename, account_statement_path(import.account_statement), class: "text-primary hover:text-primary-hover" %>
+
+
+ <% end %>
+
<%= t("imports.pdf_import.document_type_label") %>
-
+
<%= t("imports.document_types.#{import.document_type}", default: import.document_type&.humanize || t("imports.pdf_import.unknown_document_type", default: "Unknown")) %>
<%= t("imports.pdf_import.summary_label") %>
-
+
<%= import.ai_summary %>
diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb
index 19bd4a241..a48dd4019 100644
--- a/app/views/settings/_settings_nav.html.erb
+++ b/app/views/settings/_settings_nav.html.erb
@@ -61,7 +61,7 @@ nav_sections = [
esc
<% end %>
-