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