diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb
index 94526c310..194a55dd3 100644
--- a/app/controllers/imports_controller.rb
+++ b/app/controllers/imports_controller.rb
@@ -2,6 +2,7 @@ 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
@@ -13,7 +14,9 @@ class ImportsController < ApplicationController
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.is_a?(PdfImport) ? @import.assign_account!(account) : @import.update!(account: account)
end
redirect_to import_path(@import), notice: t("imports.update.account_saved", default: "Account saved.")
@@ -134,24 +137,32 @@ class ImportsController < ApplicationController
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 if @import.account_statement.blank? || @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)
- 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
+ return redirect_to new_import_path, alert: t("accounts.not_authorized") unless AccountStatement.statement_manager?(Current.user)
+ return redirect_to new_import_path, alert: t("imports.create.pdf_too_large", max_size: Import::MAX_PDF_SIZE / 1.megabyte) if file.size > Import::MAX_PDF_SIZE
- 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)
diff --git a/app/jobs/process_pdf_job.rb b/app/jobs/process_pdf_job.rb
index 1ed48cf49..c577806dc 100644
--- a/app/jobs/process_pdf_job.rb
+++ b/app/jobs/process_pdf_job.rb
@@ -3,9 +3,9 @@ class ProcessPdfJob < ApplicationJob
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,12 +62,11 @@ class ProcessPdfJob < ApplicationJob
end
def upload_to_vector_store(pdf_import, document_type:)
- filename = pdf_import.pdf_file.filename.to_s
file_content = pdf_import.pdf_file_content
family_document = pdf_import.family.upload_document(
file_content: file_content,
- filename: filename,
+ filename: pdf_import.pdf_filename,
metadata: { "type" => document_type }
)
@@ -85,4 +84,8 @@ class ProcessPdfJob < ApplicationJob
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 { pdf_import.update!(status: :pending) if pdf_import.importing? && pdf_import.updated_at <= 30.minutes.ago }
+ end
end
diff --git a/app/models/account_statement.rb b/app/models/account_statement.rb
index 07cc80c86..5240093fd 100644
--- a/app/models/account_statement.rb
+++ b/app/models/account_statement.rb
@@ -33,6 +33,7 @@ class AccountStatement < ApplicationRecord
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: :restrict_with_error
has_one_attached :original_file, dependent: :purge_later
enum :source, { manual_upload: "manual_upload" }, validate: true, default: "manual_upload"
@@ -360,6 +361,10 @@ class AccountStatement < ApplicationRecord
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:)
diff --git a/app/models/import.rb b/app/models/import.rb
index 71887faed..d70a19eb2 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -40,6 +40,7 @@ class Import < ApplicationRecord
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..1e1f494ef 100644
--- a/app/models/pdf_import.rb
+++ b/app/models/pdf_import.rb
@@ -2,6 +2,32 @@ class PdfImport < Import
has_one_attached :pdf_file, dependent: :purge_later
validates :document_type, inclusion: { in: DOCUMENT_TYPES }, allow_nil: true
+ validate :account_statement_matches_import
+
+ class << self
+ def create_from_upload!(family:, file:, user:)
+ statement = AccountStatement.create_from_prepared_upload!(
+ family: family,
+ account: nil,
+ prepared_upload: AccountStatement.prepare_upload!(file)
+ )
+
+ create_from_statement!(statement: statement)
+ rescue AccountStatement::DuplicateUploadError => e
+ raise unless e.statement.manageable_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_import &&
+ reusable_import.account_id == statement.account_id &&
+ reusable_import.date_format == statement.family.date_format
+
+ create!(family: statement.family, account: statement.account, account_statement: statement, date_format: statement.family.date_format, status: :pending)
+ end
+ end
def import!
raise "Account required for PDF import" unless account.present?
@@ -31,8 +57,18 @@ class PdfImport < Import
end
end
+ def assign_account!(account)
+ transaction do
+ update!(account: account)
+ if (statement = account_statement)
+ statement.lock!
+ statement.link_to_account!(account) if statement.account_id != account.id
+ end
+ end
+ end
+
def pdf_uploaded?
- pdf_file.attached?
+ statement_backed? || pdf_file.attached?
end
def ai_processed?
@@ -40,7 +76,16 @@ class PdfImport < Import
end
def process_with_ai_later
- ProcessPdfJob.perform_later(self)
+ return false unless with_lock { pending? && !ai_processed? && rows_count.zero? && pdf_uploaded? && update!(status: :importing) }
+
+ 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 +217,20 @@ class PdfImport < Import
end
def pdf_file_content
- return nil unless pdf_file.attached?
+ return @pdf_file_content if defined?(@pdf_file_content)
+ return @pdf_file_content = account_statement.original_file.download if statement_backed?
- pdf_file.download
+ @pdf_file_content = pdf_file.download if pdf_file.attached?
+ end
+
+ def pdf_filename
+ return account_statement.filename if statement_backed?
+
+ pdf_file.filename.to_s if pdf_file.attached?
+ end
+
+ def statement_backed?
+ account_statement&.original_file&.attached?
end
def required_column_keys
@@ -199,4 +255,10 @@ class PdfImport < Import
rescue ArgumentError
date_str.to_s
end
+
+ def account_statement_matches_import
+ return if account_statement.blank? || (account_statement.family_id == family_id && account_statement.pdf?)
+
+ errors.add(:account_statement, :invalid)
+ end
end
diff --git a/app/views/imports/_pdf_import.html.erb b/app/views/imports/_pdf_import.html.erb
index 068d55da9..4d82ce324 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") %>
@@ -38,7 +47,7 @@
<%= t("imports.pdf_import.select_account_hint", default: "Choose which account to import these transactions into.") %>
<% end %>
<% else %>
-
+
<%= t("imports.pdf_import.no_accounts", default: "No accounts available. Please create an account first.") %>
<%= render DS::Link.new(text: t("imports.pdf_import.create_account", default: "Create Account"), href: new_account_path(return_to: import_path(import)), variant: "primary", full_width: true, frame: :modal) %>
@@ -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/config/locales/views/imports/en.yml b/config/locales/views/imports/en.yml
index d97b7cf21..7d83f0793 100644
--- a/config/locales/views/imports/en.yml
+++ b/config/locales/views/imports/en.yml
@@ -384,6 +384,7 @@ en:
pdf_too_large: PDF file is too large. Maximum size is %{max_size}MB.
pdf_processing: Your PDF is being processed. You will receive an email when analysis is complete.
invalid_pdf: The uploaded file is not a valid PDF.
+ duplicate_pdf_unavailable: This PDF is already stored as a statement you cannot access.
document_too_large: Document file is too large. Maximum size is %{max_size}MB.
invalid_document_file_type: Invalid document file type for the active vector store.
document_uploaded: Document uploaded successfully.
@@ -425,6 +426,7 @@ en:
complete_title: Document analyzed
complete_description: We've analyzed your PDF and here's what we found.
document_type_label: Document Type
+ source_statement: Source statement
summary_label: Summary
email_sent_notice: An email has been sent to you with next steps.
back_to_imports: Back to imports
diff --git a/db/migrate/20260513120000_add_account_statement_to_imports.rb b/db/migrate/20260513120000_add_account_statement_to_imports.rb
new file mode 100644
index 000000000..3d346212a
--- /dev/null
+++ b/db/migrate/20260513120000_add_account_statement_to_imports.rb
@@ -0,0 +1,5 @@
+class AddAccountStatementToImports < ActiveRecord::Migration[7.2]
+ def change
+ add_reference :imports, :account_statement, type: :uuid, foreign_key: { on_delete: :nullify }, index: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c7fcf54ae..12e8ad30c 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -923,6 +923,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_19_100000) do
t.jsonb "extracted_data"
t.jsonb "expected_record_counts", default: {}, null: false
t.jsonb "readback_verification", default: {}, null: false
+ t.uuid "account_statement_id"
+ t.index ["account_statement_id"], name: "index_imports_on_account_statement_id"
t.index ["family_id"], name: "index_imports_on_family_id"
end
@@ -1930,6 +1932,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_19_100000) do
add_foreign_key "impersonation_sessions", "users", column: "impersonated_id"
add_foreign_key "impersonation_sessions", "users", column: "impersonator_id"
add_foreign_key "import_rows", "imports"
+ add_foreign_key "imports", "account_statements", on_delete: :nullify
add_foreign_key "imports", "families"
add_foreign_key "indexa_capital_accounts", "indexa_capital_items"
add_foreign_key "indexa_capital_items", "families"
diff --git a/test/controllers/imports_controller_test.rb b/test/controllers/imports_controller_test.rb
index eea32693a..1b48bd953 100644
--- a/test/controllers/imports_controller_test.rb
+++ b/test/controllers/imports_controller_test.rb
@@ -1,6 +1,8 @@
require "test_helper"
class ImportsControllerTest < ActionDispatch::IntegrationTest
+ include ActiveJob::TestHelper
+
setup do
sign_in @user = users(:family_admin)
ensure_tailwind_build
@@ -85,18 +87,242 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
@user.family.expects(:upload_document).never
assert_difference "Import.count", 1 do
+ assert_difference "AccountStatement.count", 1 do
+ post imports_url, params: {
+ import: {
+ type: "DocumentImport",
+ import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
+ }
+ }
+ end
+ end
+
+ created_import = Import.order(:created_at).last
+ assert_equal "PdfImport", created_import.type
+ assert_equal AccountStatement.order(:created_at).last, created_import.account_statement
+ assert_not created_import.pdf_file.attached?
+ assert_redirected_to import_url(created_import)
+ assert_equal I18n.t("imports.create.pdf_processing"), flash[:notice]
+ end
+
+ test "uploads pdf import through account statement" do
+ assert_difference "AccountStatement.count", 1 do
+ assert_difference "Import.where(type: 'PdfImport').count", 1 do
+ post imports_url, params: {
+ import: {
+ import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
+ }
+ }
+ end
+ end
+
+ statement = AccountStatement.order(:created_at).last
+ created_import = PdfImport.order(:created_at).last
+ assert_equal statement, created_import.account_statement
+ assert_not created_import.pdf_file.attached?
+ assert_redirected_to import_url(created_import)
+ assert_equal I18n.t("imports.create.pdf_processing"), flash[:notice]
+ end
+
+ test "guest cannot create statement backed pdf import" do
+ sign_in users(:intro_user)
+
+ assert_no_difference [ "AccountStatement.count", "Import.where(type: 'PdfImport').count" ] do
+ assert_no_enqueued_jobs only: ProcessPdfJob do
+ post imports_url, params: {
+ import: {
+ import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
+ }
+ }
+ end
+ end
+
+ assert_redirected_to new_import_url
+ assert_equal I18n.t("accounts.not_authorized"), flash[:alert]
+ end
+
+ test "duplicate pdf import reuses account statement" do
+ statement = AccountStatement.create_from_upload!(
+ family: @user.family,
+ account: nil,
+ file: uploaded_file(
+ filename: "existing_statement.pdf",
+ content_type: "application/pdf",
+ content: file_fixture("imports/sample_bank_statement.pdf").binread
+ )
+ )
+
+ assert_no_difference "AccountStatement.count" do
+ assert_difference "Import.where(type: 'PdfImport').count", 1 do
+ post imports_url, params: {
+ import: {
+ import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
+ }
+ }
+ end
+ end
+
+ created_import = PdfImport.order(:created_at).last
+ assert_equal statement, created_import.account_statement
+ assert_redirected_to import_url(created_import)
+ end
+
+ test "duplicate pdf import does not enqueue processing twice for reused import" do
+ assert_difference "AccountStatement.count", 1 do
+ assert_difference "Import.where(type: 'PdfImport').count", 1 do
+ assert_enqueued_jobs 1, only: ProcessPdfJob do
+ post imports_url, params: {
+ import: {
+ import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
+ }
+ }
+
+ post imports_url, params: {
+ import: {
+ import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
+ }
+ }
+ end
+ end
+ end
+
+ created_import = PdfImport.order(:created_at).last
+ assert_equal "importing", created_import.status
+ assert_redirected_to import_url(created_import)
+ end
+
+ test "duplicate pdf import for inaccessible statement does not create import" do
+ AccountStatement.create_from_upload!(
+ family: @user.family,
+ account: accounts(:investment),
+ file: uploaded_file(
+ filename: "existing_statement.pdf",
+ content_type: "application/pdf",
+ content: file_fixture("imports/sample_bank_statement.pdf").binread
+ )
+ )
+
+ sign_in users(:family_member)
+
+ assert_no_difference [ "AccountStatement.count", "Import.where(type: 'PdfImport').count" ] do
post imports_url, params: {
import: {
- type: "DocumentImport",
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
}
}
end
- created_import = Import.order(:created_at).last
- assert_equal "PdfImport", created_import.type
- assert_redirected_to import_url(created_import)
- assert_equal I18n.t("imports.create.pdf_processing"), flash[:notice]
+ assert_redirected_to new_import_url
+ assert_equal I18n.t("imports.create.duplicate_pdf_unavailable"), flash[:alert]
+ end
+
+ test "read only shared user cannot reuse duplicate statement backed pdf import" do
+ AccountStatement.create_from_upload!(
+ family: @user.family,
+ account: accounts(:credit_card),
+ file: uploaded_file(
+ filename: "existing_statement.pdf",
+ content_type: "application/pdf",
+ content: file_fixture("imports/sample_bank_statement.pdf").binread
+ )
+ )
+
+ sign_in users(:family_member)
+
+ assert_no_difference [ "AccountStatement.count", "Import.where(type: 'PdfImport').count" ] do
+ assert_no_enqueued_jobs only: ProcessPdfJob do
+ post imports_url, params: {
+ import: {
+ import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
+ }
+ }
+ end
+ end
+
+ assert_redirected_to new_import_url
+ assert_equal I18n.t("imports.create.duplicate_pdf_unavailable"), flash[:alert]
+ end
+
+ test "setting statement backed pdf import account links source statement" do
+ statement = AccountStatement.create_from_upload!(
+ family: @user.family,
+ account: nil,
+ file: uploaded_file(
+ filename: "statement.pdf",
+ content_type: "application/pdf",
+ content: file_fixture("imports/sample_bank_statement.pdf").binread
+ )
+ )
+ pdf_import = PdfImport.create_from_statement!(statement: statement)
+ account = accounts(:depository)
+
+ patch import_url(pdf_import), params: { import: { account_id: account.id } }
+
+ assert_redirected_to import_url(pdf_import)
+ assert_equal I18n.t("imports.update.account_saved", default: "Account saved."), flash[:notice]
+ assert_equal account, pdf_import.reload.account
+ assert_equal account, statement.reload.account
+ end
+
+ test "read only shared user cannot link source statement through pdf import account update" do
+ account = accounts(:credit_card)
+ statement = AccountStatement.create_from_upload!(
+ family: @user.family,
+ account: nil,
+ file: uploaded_file(
+ filename: "statement.pdf",
+ content_type: "application/pdf",
+ content: file_fixture("imports/sample_bank_statement.pdf").binread
+ )
+ )
+ pdf_import = PdfImport.create_from_statement!(statement: statement)
+
+ sign_in users(:family_member)
+ patch import_url(pdf_import), params: { import: { account_id: account.id } }
+
+ assert_redirected_to account_url(account)
+ assert_equal I18n.t("accounts.not_authorized"), flash[:alert]
+ assert_nil pdf_import.reload.account
+ assert_nil statement.reload.account
+ end
+
+ test "user cannot view statement backed pdf import for inaccessible statement" do
+ statement = AccountStatement.create_from_upload!(
+ family: @user.family,
+ account: accounts(:investment),
+ file: uploaded_file(
+ filename: "statement.pdf",
+ content_type: "application/pdf",
+ content: file_fixture("imports/sample_bank_statement.pdf").binread
+ )
+ )
+ pdf_import = PdfImport.create_from_statement!(statement: statement)
+
+ sign_in users(:family_member)
+ get import_url(pdf_import)
+
+ assert_response :not_found
+ end
+
+ test "read only shared user cannot publish statement backed pdf import" do
+ account = accounts(:credit_card)
+ statement = AccountStatement.create_from_upload!(
+ family: @user.family,
+ account: account,
+ file: uploaded_file(
+ filename: "statement.pdf",
+ content_type: "application/pdf",
+ content: file_fixture("imports/sample_bank_statement.pdf").binread
+ )
+ )
+ pdf_import = PdfImport.create_from_statement!(statement: statement)
+ PdfImport.any_instance.expects(:publish_later).never
+
+ sign_in users(:family_member)
+ post publish_import_url(pdf_import)
+
+ assert_redirected_to account_url(account)
+ assert_equal I18n.t("accounts.not_authorized"), flash[:alert]
end
test "rejects unsupported document type for DocumentImport option" do
diff --git a/test/jobs/process_pdf_job_test.rb b/test/jobs/process_pdf_job_test.rb
index 29d4a4977..1d9540dcc 100644
--- a/test/jobs/process_pdf_job_test.rb
+++ b/test/jobs/process_pdf_job_test.rb
@@ -18,12 +18,22 @@ class ProcessPdfJobTest < ActiveJob::TestCase
test "skips if PDF not uploaded" do
assert_not @import.pdf_uploaded?
+ @import.update_columns(status: "importing", updated_at: 31.minutes.ago)
ProcessPdfJob.perform_now(@import)
assert_equal "pending", @import.reload.status
end
+ test "skips if PDF not uploaded without releasing fresh processing claim" do
+ assert_not @import.pdf_uploaded?
+ @import.update!(status: :importing)
+
+ ProcessPdfJob.perform_now(@import)
+
+ assert_equal "importing", @import.reload.status
+ end
+
test "skips if already processed" do
processed_import = imports(:pdf_processed)
@@ -33,6 +43,29 @@ class ProcessPdfJobTest < ActiveJob::TestCase
assert_equal "complete", processed_import.reload.status
end
+ test "skips already processed importing import and releases processing claim" do
+ processed_import = imports(:pdf_processed)
+ attach_pdf!(processed_import)
+ processed_import.update!(document_type: "financial_document")
+ processed_import.update_columns(status: "importing", updated_at: 31.minutes.ago)
+ processed_import.expects(:process_with_ai).never
+
+ ProcessPdfJob.perform_now(processed_import)
+
+ assert_equal "pending", processed_import.reload.status
+ end
+
+ test "skips already processed importing import without releasing fresh processing claim" do
+ processed_import = imports(:pdf_processed)
+ attach_pdf!(processed_import)
+ processed_import.update!(status: :importing, document_type: "financial_document")
+ processed_import.expects(:process_with_ai).never
+
+ ProcessPdfJob.perform_now(processed_import)
+
+ assert_equal "importing", processed_import.reload.status
+ end
+
test "uploads non-bank PDF to vector store with classified type metadata" do
pdf_content = attach_pdf!(@import)
process_result = Struct.new(:document_type).new("financial_document")
diff --git a/test/models/pdf_import_test.rb b/test/models/pdf_import_test.rb
index e6d89bfe2..df4ebdc91 100644
--- a/test/models/pdf_import_test.rb
+++ b/test/models/pdf_import_test.rb
@@ -13,6 +13,16 @@ class PdfImportTest < ActiveSupport::TestCase
assert_not @import.pdf_uploaded?
end
+ test "pdf_uploaded? returns true for statement backed import" do
+ statement = create_pdf_statement
+ import = PdfImport.create_from_statement!(statement: statement)
+
+ assert import.pdf_uploaded?
+ assert import.statement_backed?
+ assert_equal statement.original_file.download, import.pdf_file_content
+ assert_equal statement.filename, import.pdf_filename
+ end
+
test "ai_processed? returns false when no summary present" do
assert_not @import.ai_processed?
end
@@ -77,9 +87,40 @@ class PdfImportTest < ActiveSupport::TestCase
end
test "process_with_ai_later enqueues ProcessPdfJob" do
- assert_enqueued_with job: ProcessPdfJob, args: [ @import ] do
- @import.process_with_ai_later
+ import = PdfImport.create_from_statement!(statement: create_pdf_statement)
+
+ assert_enqueued_with job: ProcessPdfJob, args: [ import ] do
+ assert import.process_with_ai_later
end
+
+ assert_equal "importing", import.reload.status
+ end
+
+ test "process_with_ai_later does not enqueue duplicate jobs while importing" do
+ import = PdfImport.create_from_statement!(statement: create_pdf_statement)
+
+ assert_enqueued_jobs 1, only: ProcessPdfJob do
+ assert import.process_with_ai_later
+ assert_not import.reload.process_with_ai_later
+ end
+
+ assert_equal "importing", import.reload.status
+ end
+
+ test "process_with_ai_later does not claim import without pdf content" do
+ assert_no_enqueued_jobs only: ProcessPdfJob do
+ assert_not @import.process_with_ai_later
+ end
+
+ assert_equal "pending", @import.reload.status
+ end
+
+ test "process_with_ai_later resets pending when enqueue fails" do
+ import = PdfImport.create_from_statement!(statement: create_pdf_statement)
+ ProcessPdfJob.stubs(:perform_later).raises(StandardError, "queue offline")
+
+ assert_not import.process_with_ai_later
+ assert_equal "pending", import.reload.status
end
test "generate_rows_from_extracted_data creates import rows" do
@@ -163,4 +204,111 @@ class PdfImportTest < ActiveSupport::TestCase
assert_not ActiveStorage::Attachment.exists?(attachment_id)
end
+
+ test "destroying statement backed import keeps statement file" do
+ statement = create_pdf_statement
+ import = PdfImport.create_from_statement!(statement: statement)
+ attachment_id = statement.original_file.id
+
+ perform_enqueued_jobs do
+ import.destroy!
+ end
+
+ assert ActiveStorage::Attachment.exists?(attachment_id)
+ end
+
+ test "statement backed import prevents source statement destroy" do
+ statement = create_pdf_statement
+ import = PdfImport.create_from_statement!(statement: statement)
+
+ assert_no_difference "AccountStatement.count" do
+ assert_not statement.destroy
+ end
+
+ assert_equal statement, import.reload.account_statement
+ end
+
+ test "statement backed import memoizes pdf content" do
+ statement = create_pdf_statement
+ import = PdfImport.create_from_statement!(statement: statement)
+ statement.original_file.expects(:download).once.returns("%PDF-test")
+
+ assert_equal "%PDF-test", import.pdf_file_content
+ assert_equal "%PDF-test", import.pdf_file_content
+ end
+
+ test "statement backed import reuse requires current account and date format" do
+ statement = create_pdf_statement
+ stale_import = PdfImport.create_from_statement!(statement: statement)
+ formats = Family::DATE_FORMATS.map(&:last)
+ alternate_date_format = (formats - [ statement.family.date_format ]).first || "#{statement.family.date_format}-alternate"
+ stale_import.update!(account: nil, date_format: alternate_date_format)
+
+ fresh_import = PdfImport.create_from_statement!(statement: statement)
+
+ assert_not_equal stale_import, fresh_import
+ assert_equal statement.account, fresh_import.account
+ assert_equal statement.family.date_format, fresh_import.date_format
+ end
+
+ test "statement backed import reuses matching reusable import" do
+ statement = create_pdf_statement
+ existing_import = PdfImport.create_from_statement!(statement: statement)
+
+ assert_equal existing_import, PdfImport.create_from_statement!(statement: statement)
+ end
+
+ test "assigning account links statement backed import statement" do
+ statement = create_pdf_statement(account: nil)
+ import = PdfImport.create_from_statement!(statement: statement)
+ account = accounts(:depository)
+
+ import.assign_account!(account)
+
+ assert_equal account, import.reload.account
+ assert_equal account, statement.reload.account
+ assert statement.linked?
+ end
+
+ test "statement backed import requires pdf statement" do
+ csv_statement = AccountStatement.create_from_upload!(
+ family: @import.family,
+ account: nil,
+ file: uploaded_file(filename: "statement.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
+ )
+ import = PdfImport.new(family: @import.family, account_statement: csv_statement)
+
+ assert_not import.valid?
+ assert import.errors[:account_statement].present?
+ end
+
+ test "statement backed import requires statement from same family" do
+ statement = AccountStatement.create_from_upload!(
+ family: families(:empty),
+ account: nil,
+ file: uploaded_file(
+ filename: "other_family_statement.pdf",
+ content_type: "application/pdf",
+ content: file_fixture("imports/sample_bank_statement.pdf").binread
+ )
+ )
+ import = PdfImport.new(family: @import.family, account_statement: statement)
+
+ assert_not import.valid?
+ assert import.errors[:account_statement].present?
+ end
+
+ private
+
+ def create_pdf_statement(account: accounts(:depository))
+ AccountStatement.create_from_upload!(
+ family: @import.family,
+ account: account,
+ file: uploaded_file(
+ filename: "sample_bank_statement.pdf",
+ content_type: "application/pdf",
+ content: file_fixture("imports/sample_bank_statement.pdf").binread
+ )
+ )
+ end
end