Files
sure/app/models/pdf_import.rb
Juan José Mata 0fb9d60ee6 Use dependent: :purge_later for ActiveRecord attachments (#882)
* Use dependent: :purge_later for user profile_image cleanup

This is a simpler alternative to PR #787's callback-based approach.
Instead of adding a custom callback and method, we use Rails' built-in
`dependent: :purge_later` option which is already used by FamilyExport
and other models in the codebase.

This single-line change ensures orphaned ActiveStorage attachments are
automatically purged when a user is destroyed, without the overhead of
querying all attachments manually.

https://claude.ai/code/session_01Np3deHEAJqCBfz3aY7c3Tk

* Add dependent: :purge_later to all ActiveStorage attachments

Extends the attachment cleanup from PR #787 to cover ALL models with
ActiveStorage attachments, not just User.profile_image.

Models updated:
- PdfImport.pdf_file - prevents orphaned PDF files from imports
- Account.logo - prevents orphaned account logos
- PlaidItem.logo, SimplefinItem.logo, SnaptradeItem.logo,
  CoinstatsItem.logo, CoinbaseItem.logo, LunchflowItem.logo,
  MercuryItem.logo, EnableBankingItem.logo - prevents orphaned
  provider logos

This ensures that when a family is deleted (cascade from last user
purge), all associated storage files are properly cleaned up via
Rails' built-in dependent: :purge_later mechanism.

https://claude.ai/code/session_01Np3deHEAJqCBfz3aY7c3Tk

* Make sure `Provider` generator adds it

* Fix tests

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-03 15:45:25 +01:00

190 lines
4.3 KiB
Ruby

class PdfImport < Import
has_one_attached :pdf_file, dependent: :purge_later
validates :document_type, inclusion: { in: DOCUMENT_TYPES }, allow_nil: true
def import!
raise "Account required for PDF import" unless account.present?
transaction do
mappings.each(&:create_mappable!)
new_transactions = rows.map do |row|
category = mappings.categories.mappable_for(row.category)
Transaction.new(
category: category,
entry: Entry.new(
account: account,
date: row.date_iso,
amount: row.signed_amount,
name: row.name,
currency: row.currency,
notes: row.notes,
import: self,
import_locked: true
)
)
end
Transaction.import!(new_transactions, recursive: true) if new_transactions.any?
end
end
def pdf_uploaded?
pdf_file.attached?
end
def ai_processed?
ai_summary.present?
end
def process_with_ai_later
ProcessPdfJob.perform_later(self)
end
def process_with_ai
provider = Provider::Registry.get_provider(:openai)
raise "AI provider not configured" unless provider
raise "AI provider does not support PDF processing" unless provider.supports_pdf_processing?
response = provider.process_pdf(
pdf_content: pdf_file_content,
family: family
)
unless response.success?
error_message = response.error&.message || "Unknown PDF processing error"
raise error_message
end
result = response.data
update!(
ai_summary: result.summary,
document_type: result.document_type
)
result
end
def extract_transactions
return unless bank_statement?
provider = Provider::Registry.get_provider(:openai)
raise "AI provider not configured" unless provider
response = provider.extract_bank_statement(
pdf_content: pdf_file_content,
family: family
)
unless response.success?
error_message = response.error&.message || "Unknown extraction error"
raise error_message
end
update!(extracted_data: response.data)
response.data
end
def bank_statement?
document_type == "bank_statement"
end
def has_extracted_transactions?
extracted_data.present? && extracted_data["transactions"].present?
end
def extracted_transactions
extracted_data&.dig("transactions") || []
end
def generate_rows_from_extracted_data
transaction do
rows.destroy_all
unless has_extracted_transactions?
update_column(:rows_count, 0)
return
end
currency = account&.currency || family.currency
mapped_rows = extracted_transactions.map do |txn|
{
import_id: id,
date: format_date_for_import(txn["date"]),
amount: txn["amount"].to_s,
name: txn["name"].to_s,
category: txn["category"].to_s,
notes: txn["notes"].to_s,
currency: currency
}
end
Import::Row.insert_all!(mapped_rows) if mapped_rows.any?
update_column(:rows_count, mapped_rows.size)
end
end
def send_next_steps_email(user)
PdfImportMailer.with(
user: user,
pdf_import: self
).next_steps.deliver_later
end
def uploaded?
pdf_uploaded?
end
def configured?
ai_processed? && rows_count > 0
end
def cleaned?
configured? && rows.all?(&:valid?)
end
def publishable?
account.present? && bank_statement? && cleaned? && mappings.all?(&:valid?)
end
def column_keys
%i[date amount name category notes]
end
def requires_csv_workflow?
false
end
def pdf_file_content
return nil unless pdf_file.attached?
pdf_file.download
end
def required_column_keys
%i[date amount]
end
def mapping_steps
base = []
# Only include CategoryMapping if rows have non-empty categories
base << Import::CategoryMapping if rows.where.not(category: [ nil, "" ]).exists?
# Note: PDF imports use direct account selection in the UI, not AccountMapping
# AccountMapping is designed for CSV imports where rows have different account values
base
end
private
def format_date_for_import(date_str)
return "" if date_str.blank?
Date.parse(date_str).strftime(date_format)
rescue ArgumentError
date_str.to_s
end
end