From 6f8858b1a6df2ecc86c35a0913b37614e416626d Mon Sep 17 00:00:00 2001 From: MkDev11 <94194147+MkDev11@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:44:25 -0500 Subject: [PATCH] feat/Add AI-Powered Bank Statement Import (step 1, PDF import & analysis) (#808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add PDF import with AI-powered document analysis This enhances the import functionality to support PDF files with AI-powered document analysis. When a PDF is uploaded, it is processed by AI to: - Identify the document type (bank statement, credit card statement, etc.) - Generate a summary of the document contents - Extract key metadata (institution, dates, balances, transaction count) After processing, an email is sent to the user asking for next steps. Key changes: - Add PdfImport model for handling PDF document imports - Add Provider::Openai::PdfProcessor for AI document analysis - Add ProcessPdfJob for async PDF processing - Add PdfImportMailer for user notification emails - Update imports controller to detect and handle PDF uploads - Add PDF import option to the new import page - Add i18n translations for all new strings - Add comprehensive tests for the new functionality * Add bank statement import with AI extraction - Create ImportBankStatement assistant function for MCP - Add BankStatementExtractor with chunked processing for small context windows - Register function in assistant configurable - Make PdfImport#pdf_file_content public for extractor access - Increase OpenAI request timeout to 600s for slow local models - Increase DB connection pool to 20 for concurrent operations Tested with M-Pesa bank statement via remote Ollama (qwen3:8b): - Successfully extracted 18 transactions - Generated CSV and created TransactionImport - Works with 3000 char chunks for small context windows * Add pdf-reader gem dependency The BankStatementExtractor uses PDF::Reader to parse bank statement PDFs, but the gem was not properly declared in the Gemfile. This would cause NameError in production when processing bank statements. Added pdf-reader ~> 2.12 to Gemfile dependencies. * Fix transaction deduplication to preserve legitimate duplicates The previous deduplication logic removed ALL duplicate transactions based on [date, amount, name], which would drop legitimate same-day duplicates like multiple ATM withdrawals or card authorizations. Changed to only deduplicate transactions that appear in consecutive chunks (chunking artifacts) while preserving all legitimate duplicates within the same chunk or non-adjacent chunks. * Refactor bank statement extraction to use public provider method Address code review feedback: - Add public extract_bank_statement method to Provider::Openai - Remove direct access to private client via send(:client) - Update ImportBankStatement to use new public method - Add require 'set' to BankStatementExtractor - Remove PII-sensitive content from error logs - Add defensive check for nil response.error - Handle oversized PDF pages in chunking logic - Remove unused process_native and process_generic methods - Update email copy to reflect feature availability - Add guard for nil document_type in email template - Document pdf-reader gem rationale in Gemfile Tested with both OpenAI (gpt-4o) and Ollama (qwen3:8b): - OpenAI: 49 transactions extracted in 30s - Ollama: 40 transactions extracted in 368s - All encapsulation and error handling working correctly * Update schema.rb with ai_summary and document_type columns * Address PR #808 review comments - Rename :csv_file to :import_file across controllers/views/tests - Add PDF test fixture (sample_bank_statement.pdf) - Add supports_pdf_processing? method for graceful degradation - Revert unrelated database.yml pool change (600->3) - Remove month_start_day schema bleed from other PR - Fix PdfProcessor: use .strip instead of .strip_heredoc - Add server-side PDF magic byte validation - Conditionally show PDF import option when AI provider available - Fix ProcessPdfJob: sanitize errors, handle update failure - Move pdf_file attachment from Import to PdfImport - Document deduplication logic limitations - Fix ImportBankStatement: catch specific exceptions only - Remove unnecessary require 'set' - Remove dead json_schema method from PdfProcessor - Reduce default OpenAI timeout from 600s to 60s - Fix nil guard in text mailer template - Add require 'csv' to ImportBankStatement - Remove Gemfile pdf-reader comment * Fix RuboCop indentation in ProcessPdfJob * Refactor PDF import check to use model predicate method Replace is_a?(PdfImport) type check with requires_csv_workflow? predicate that leverages STI inheritance for cleaner controller logic. * Fix missing 'unknown' locale key and schema version mismatch - Add 'unknown: Unknown Document' to document_types locale - Fix schema version to match latest migration (2026_01_24_180211) * Document OPENAI_REQUEST_TIMEOUT env variable Added to .env.local.example and docs/hosting/ai.md * Rename ALLOWED_MIME_TYPES to ALLOWED_CSV_MIME_TYPES for clarity * Add comment explaining requires_csv_workflow? predicate * Remove redundant required_column_keys from PdfImport Base class already returns [] by default * Add ENV toggle to disable PDF processing for non-vision endpoints OPENAI_SUPPORTS_PDF_PROCESSING=false can be used for OpenAI-compatible endpoints (e.g., Ollama) that don't support vision/PDF processing. * Wire up transaction extraction for PDF bank statements - Add extracted_data JSONB column to imports - Add extract_transactions method to PdfImport - Call extraction in ProcessPdfJob for bank statements - Store transactions in extracted_data for later review * Fix ProcessPdfJob retry logic, sanitize and localize errors - Allow retries after partial success (classification ok, extraction failed) - Log sanitized error message instead of raw message to avoid data leakage - Use i18n for user-facing error messages * Add vision-capable model validation for PDF processing * Fix drag-and-drop test to use correct field name csv_file * Schema bleedover from another branch * Fix drag-drop import form field name to match controller * Add vision capability guard to process_pdf method --------- Co-authored-by: Claude Co-authored-by: mkdev11 Co-authored-by: Juan José Mata --- .env.local.example | 2 + Gemfile | 1 + Gemfile.lock | 13 + app/controllers/api/v1/imports_controller.rb | 2 +- app/controllers/import/uploads_controller.rb | 4 +- app/controllers/imports_controller.rb | 51 +++- app/jobs/process_pdf_job.rb | 54 ++++ app/mailers/pdf_import_mailer.rb | 12 + app/models/assistant/configurable.rb | 3 +- .../function/import_bank_statement.rb | 188 +++++++++++++ app/models/import.rb | 16 +- app/models/pdf_import.rb | 110 ++++++++ app/models/provider/llm_concept.rb | 10 + app/models/provider/openai.rb | 63 +++++ .../openai/bank_statement_extractor.rb | 213 ++++++++++++++ app/models/provider/openai/pdf_processor.rb | 265 ++++++++++++++++++ app/views/import/uploads/show.html.erb | 2 +- app/views/imports/_pdf_import.html.erb | 84 ++++++ app/views/imports/new.html.erb | 29 ++ app/views/imports/show.html.erb | 6 +- .../pdf_import_mailer/next_steps.html.erb | 30 ++ .../pdf_import_mailer/next_steps.text.erb | 28 ++ app/views/transactions/_list.html.erb | 2 +- .../locales/mailers/pdf_import_mailer/en.yml | 5 + config/locales/views/imports/en.yml | 39 +++ config/locales/views/pdf_import_mailer/en.yml | 17 ++ .../20260116100000_add_pdf_import_support.rb | 6 + ...129200129_add_extracted_data_to_imports.rb | 5 + db/schema.rb | 5 +- docs/hosting/ai.md | 3 + .../import/uploads_controller_test.rb | 4 +- .../files/imports/sample_bank_statement.pdf | Bin 0 -> 52633 bytes test/fixtures/imports.yml | 12 + test/jobs/process_pdf_job_test.rb | 35 +++ test/mailers/pdf_import_mailer_test.rb | 21 ++ test/models/pdf_import_test.rb | 69 +++++ test/system/drag_and_drop_import_test.rb | 4 +- 37 files changed, 1388 insertions(+), 25 deletions(-) create mode 100644 app/jobs/process_pdf_job.rb create mode 100644 app/mailers/pdf_import_mailer.rb create mode 100644 app/models/assistant/function/import_bank_statement.rb create mode 100644 app/models/pdf_import.rb create mode 100644 app/models/provider/openai/bank_statement_extractor.rb create mode 100644 app/models/provider/openai/pdf_processor.rb create mode 100644 app/views/imports/_pdf_import.html.erb create mode 100644 app/views/pdf_import_mailer/next_steps.html.erb create mode 100644 app/views/pdf_import_mailer/next_steps.text.erb create mode 100644 config/locales/mailers/pdf_import_mailer/en.yml create mode 100644 config/locales/views/pdf_import_mailer/en.yml create mode 100644 db/migrate/20260116100000_add_pdf_import_support.rb create mode 100644 db/migrate/20260129200129_add_extracted_data_to_imports.rb create mode 100644 test/fixtures/files/imports/sample_bank_statement.pdf create mode 100644 test/jobs/process_pdf_job_test.rb create mode 100644 test/mailers/pdf_import_mailer_test.rb create mode 100644 test/models/pdf_import_test.rb diff --git a/.env.local.example b/.env.local.example index e91e7cf0e..b9ddcabf3 100644 --- a/.env.local.example +++ b/.env.local.example @@ -28,6 +28,8 @@ TWELVE_DATA_API_KEY = OPENAI_ACCESS_TOKEN = OPENAI_URI_BASE = OPENAI_MODEL = +# OPENAI_REQUEST_TIMEOUT: Request timeout in seconds (default: 60) +# OPENAI_SUPPORTS_PDF_PROCESSING: Set to false for endpoints without vision support (default: true) # (example: LM Studio/Docker config) OpenAI-compatible API endpoint config # OPENAI_URI_BASE = http://host.docker.internal:1234/ diff --git a/Gemfile b/Gemfile index 64dd5099e..f179d6798 100644 --- a/Gemfile +++ b/Gemfile @@ -81,6 +81,7 @@ gem "rotp", "~> 6.3" gem "rqrcode", "~> 3.0" gem "activerecord-import" gem "rubyzip", "~> 2.3" +gem "pdf-reader", "~> 2.12" # OpenID Connect, OAuth & SAML authentication gem "omniauth", "~> 2.1" diff --git a/Gemfile.lock b/Gemfile.lock index dfc8ee3c9..852d372c1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,7 @@ GEM remote: https://rubygems.org/ specs: + Ascii85 (2.0.1) aasm (5.5.1) concurrent-ruby (~> 1.0) actioncable (7.2.2.2) @@ -79,6 +80,7 @@ GEM addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) aes_key_wrap (1.1.0) + afm (1.0.0) after_commit_everywhere (1.6.0) activerecord (>= 4.2) activesupport @@ -232,6 +234,7 @@ GEM globalid (1.2.1) activesupport (>= 6.1) hashdiff (1.2.0) + hashery (2.1.2) hashie (5.0.0) heapy (0.2.0) thor @@ -446,6 +449,12 @@ GEM parser (3.3.8.0) ast (~> 2.4.1) racc + pdf-reader (2.15.1) + Ascii85 (>= 1.0, < 3.0, != 2.0.0) + afm (>= 0.2.1, < 2) + hashery (~> 2.0) + ruby-rc4 + ttfunk pg (1.5.9) plaid (41.0.0) faraday (>= 1.0.1, < 3.0) @@ -629,6 +638,7 @@ GEM faraday (>= 1) faraday-multipart (>= 1) ruby-progressbar (1.13.0) + ruby-rc4 (0.1.5) ruby-saml (1.18.1) nokogiri (>= 1.13.10) rexml @@ -712,6 +722,8 @@ GEM unicode-display_width (>= 1.1.1, < 4) thor (1.4.0) timeout (0.4.3) + ttfunk (1.8.0) + bigdecimal (~> 3.1) turbo-rails (2.0.16) actionpack (>= 7.1.0) railties (>= 7.1.0) @@ -818,6 +830,7 @@ DEPENDENCIES omniauth_openid_connect ostruct pagy + pdf-reader (~> 2.12) pg (~> 1.5) plaid posthog-ruby diff --git a/app/controllers/api/v1/imports_controller.rb b/app/controllers/api/v1/imports_controller.rb index 2b6a5a5af..b3b048bba 100644 --- a/app/controllers/api/v1/imports_controller.rb +++ b/app/controllers/api/v1/imports_controller.rb @@ -67,7 +67,7 @@ class Api::V1::ImportsController < Api::V1::BaseController }, status: :unprocessable_entity end - unless Import::ALLOWED_MIME_TYPES.include?(file.content_type) + unless Import::ALLOWED_CSV_MIME_TYPES.include?(file.content_type) return render json: { error: "invalid_file_type", message: "Invalid file type. Please upload a CSV file." diff --git a/app/controllers/import/uploads_controller.rb b/app/controllers/import/uploads_controller.rb index e51b52787..a9a185d51 100644 --- a/app/controllers/import/uploads_controller.rb +++ b/app/controllers/import/uploads_controller.rb @@ -33,7 +33,7 @@ class Import::UploadsController < ApplicationController end def csv_str - @csv_str ||= upload_params[:csv_file]&.read || upload_params[:raw_file_str] + @csv_str ||= upload_params[:import_file]&.read || upload_params[:raw_file_str] end def csv_valid?(str) @@ -48,6 +48,6 @@ class Import::UploadsController < ApplicationController end def upload_params - params.require(:import).permit(:raw_file_str, :csv_file, :col_sep) + params.require(:import).permit(:raw_file_str, :import_file, :col_sep) end end diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 227e94866..88a346838 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -25,6 +25,18 @@ class ImportsController < ApplicationController end def create + file = import_params[:import_file] + + # 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 + type = params.dig(:import, :type).to_s type = "TransactionImport" unless Import::TYPES.include?(type) @@ -35,35 +47,35 @@ class ImportsController < ApplicationController date_format: Current.family.date_format, ) - if import_params[:csv_file].present? - file = import_params[:csv_file] - + if file.present? if file.size > Import::MAX_CSV_SIZE import.destroy - redirect_to new_import_path, alert: "File is too large. Maximum size is #{Import::MAX_CSV_SIZE / 1.megabyte}MB." + redirect_to new_import_path, alert: t("imports.create.file_too_large", max_size: Import::MAX_CSV_SIZE / 1.megabyte) return end - unless Import::ALLOWED_MIME_TYPES.include?(file.content_type) + unless Import::ALLOWED_CSV_MIME_TYPES.include?(file.content_type) import.destroy - redirect_to new_import_path, alert: "Invalid file type. Please upload a CSV file." + redirect_to new_import_path, alert: t("imports.create.invalid_file_type") 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: "CSV uploaded successfully." + redirect_to import_configuration_path(import), notice: t("imports.create.csv_uploaded") else redirect_to import_upload_path(import) end end def show + return unless @import.requires_csv_workflow? + if !@import.uploaded? - redirect_to import_upload_path(@import), alert: "Please finalize your file upload." + redirect_to import_upload_path(@import), alert: t("imports.show.finalize_upload") elsif !@import.publishable? - redirect_to import_confirm_path(@import), alert: "Please finalize your mappings before proceeding." + redirect_to import_confirm_path(@import), alert: t("imports.show.finalize_mappings") end end @@ -93,6 +105,25 @@ class ImportsController < ApplicationController end def import_params - params.require(:import).permit(:csv_file) + params.require(:import).permit(:import_file) + 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 + + pdf_import = Current.family.imports.create!(type: "PdfImport") + pdf_import.pdf_file.attach(file) + pdf_import.process_with_ai_later + + redirect_to import_path(pdf_import), notice: t("imports.create.pdf_processing") + 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 new file mode 100644 index 000000000..25c31f11f --- /dev/null +++ b/app/jobs/process_pdf_job.rb @@ -0,0 +1,54 @@ +class ProcessPdfJob < ApplicationJob + queue_as :medium_priority + + def perform(pdf_import) + return unless pdf_import.is_a?(PdfImport) + return unless pdf_import.pdf_uploaded? + return if pdf_import.status == "complete" + return if pdf_import.ai_processed? && (!pdf_import.bank_statement? || pdf_import.has_extracted_transactions?) + + pdf_import.update!(status: :importing) + + begin + pdf_import.process_with_ai + + # For bank statements, extract transactions + if pdf_import.bank_statement? + Rails.logger.info("ProcessPdfJob: Extracting transactions for bank statement import #{pdf_import.id}") + pdf_import.extract_transactions + Rails.logger.info("ProcessPdfJob: Extracted #{pdf_import.extracted_transactions.size} transactions") + end + + # Find the user who created this import (first admin or any user in the family) + user = pdf_import.family.users.find_by(role: :admin) || pdf_import.family.users.first + + if user + pdf_import.send_next_steps_email(user) + end + + pdf_import.update!(status: :complete) + rescue StandardError => e + sanitized_error = sanitize_error_message(e) + Rails.logger.error("PDF processing failed for import #{pdf_import.id}: #{e.class.name} - #{sanitized_error}") + begin + pdf_import.update!(status: :failed, error: sanitized_error) + rescue StandardError => update_error + Rails.logger.error("Failed to update import status: #{update_error.message}") + end + raise + end + end + + private + + def sanitize_error_message(error) + case error + when RuntimeError, ArgumentError + I18n.t("imports.pdf_import.processing_failed_with_message", + message: error.message.truncate(500)) + else + I18n.t("imports.pdf_import.processing_failed_generic", + error: error.class.name.demodulize) + end + end +end diff --git a/app/mailers/pdf_import_mailer.rb b/app/mailers/pdf_import_mailer.rb new file mode 100644 index 000000000..5f9f759d7 --- /dev/null +++ b/app/mailers/pdf_import_mailer.rb @@ -0,0 +1,12 @@ +class PdfImportMailer < ApplicationMailer + def next_steps + @user = params[:user] + @pdf_import = params[:pdf_import] + @import_url = import_url(@pdf_import) + + mail( + to: @user.email, + subject: t(".subject", product: product_name) + ) + end +end diff --git a/app/models/assistant/configurable.rb b/app/models/assistant/configurable.rb index a2898c30b..2aae1eb06 100644 --- a/app/models/assistant/configurable.rb +++ b/app/models/assistant/configurable.rb @@ -19,7 +19,8 @@ module Assistant::Configurable Assistant::Function::GetAccounts, Assistant::Function::GetHoldings, Assistant::Function::GetBalanceSheet, - Assistant::Function::GetIncomeStatement + Assistant::Function::GetIncomeStatement, + Assistant::Function::ImportBankStatement ] end diff --git a/app/models/assistant/function/import_bank_statement.rb b/app/models/assistant/function/import_bank_statement.rb new file mode 100644 index 000000000..b0cd02906 --- /dev/null +++ b/app/models/assistant/function/import_bank_statement.rb @@ -0,0 +1,188 @@ +require "csv" + +class Assistant::Function::ImportBankStatement < Assistant::Function + class << self + def name + "import_bank_statement" + end + + def description + <<~INSTRUCTIONS + Use this to import transactions from a bank statement PDF that has already been uploaded. + + This function will: + 1. Extract transaction data from the PDF using AI + 2. Create a transaction import with the extracted data + 3. Return the import ID and extracted transactions for review + + The PDF must have already been uploaded via the PDF import feature. + Only use this for PDFs that are identified as bank statements. + + Example: + + ``` + import_bank_statement({ + pdf_import_id: "abc123-def456", + account_id: "xyz789" + }) + ``` + + If account_id is not provided, you should ask the user which account to import to. + INSTRUCTIONS + end + end + + def strict_mode? + false + end + + def params_schema + build_schema( + required: [ "pdf_import_id" ], + properties: { + pdf_import_id: { + type: "string", + description: "The ID of the PDF import to extract transactions from" + }, + account_id: { + type: "string", + description: "The ID of the account to import transactions into. If not provided, will return available accounts." + } + } + ) + end + + def call(params = {}) + pdf_import = family.imports.find_by(id: params["pdf_import_id"], type: "PdfImport") + + unless pdf_import + return { + success: false, + error: "PDF import not found", + message: "Could not find a PDF import with ID: #{params["pdf_import_id"]}" + } + end + + unless pdf_import.document_type == "bank_statement" + return { + success: false, + error: "not_bank_statement", + message: "This PDF is not a bank statement. Document type: #{pdf_import.document_type}", + available_actions: [ "Use a different PDF that is a bank statement" ] + } + end + + # If no account specified, return available accounts + if params["account_id"].blank? + return { + success: false, + error: "account_required", + message: "Please specify which account to import transactions into", + available_accounts: family.accounts.visible.depository.map { |a| { id: a.id, name: a.name } } + } + end + + account = family.accounts.find_by(id: params["account_id"]) + unless account + return { + success: false, + error: "account_not_found", + message: "Account not found", + available_accounts: family.accounts.visible.depository.map { |a| { id: a.id, name: a.name } } + } + end + + # Extract transactions from the PDF using provider + provider = Provider::Registry.get_provider(:openai) + unless provider + return { + success: false, + error: "provider_not_configured", + message: "OpenAI provider is not configured" + } + end + + response = provider.extract_bank_statement( + pdf_content: pdf_import.pdf_file_content, + model: openai_model, + family: family + ) + + unless response.success? + error_message = response.error&.message || "Unknown extraction error" + return { + success: false, + error: "extraction_failed", + message: "Failed to extract transactions: #{error_message}" + } + end + + result = response.data + + if result[:transactions].blank? + return { + success: false, + error: "no_transactions_found", + message: "Could not extract any transactions from the bank statement" + } + end + + # Create a CSV from extracted transactions + csv_content = generate_csv(result[:transactions]) + + # Create a TransactionImport + import = family.imports.create!( + type: "TransactionImport", + account: account, + raw_file_str: csv_content, + date_col_label: "date", + amount_col_label: "amount", + name_col_label: "name", + category_col_label: "category", + notes_col_label: "notes", + date_format: "%Y-%m-%d", + signage_convention: "inflows_positive" + ) + + import.generate_rows_from_csv + + { + success: true, + import_id: import.id, + transaction_count: result[:transactions].size, + transactions_preview: result[:transactions].first(5), + statement_period: result[:period], + account_holder: result[:account_holder], + message: "Successfully extracted #{result[:transactions].size} transactions. Import created with ID: #{import.id}. Review and publish when ready." + } + rescue Provider::ProviderError, Faraday::Error, Timeout::Error, RuntimeError => e + Rails.logger.error("ImportBankStatement error: #{e.class.name} - #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + { + success: false, + error: "extraction_failed", + message: "Failed to extract transactions: #{e.message.truncate(200)}" + } + end + + private + + def generate_csv(transactions) + CSV.generate do |csv| + csv << %w[date amount name category notes] + transactions.each do |txn| + csv << [ + txn[:date], + txn[:amount], + txn[:name] || txn[:description], + txn[:category], + txn[:notes] + ] + end + end + end + + def openai_model + ENV["OPENAI_MODEL"].presence || Provider::Openai::DEFAULT_MODEL + end +end diff --git a/app/models/import.rb b/app/models/import.rb index 141a1ce05..203ed3a1a 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -3,9 +3,13 @@ class Import < ApplicationRecord MappingError = Class.new(StandardError) MAX_CSV_SIZE = 10.megabytes - ALLOWED_MIME_TYPES = %w[text/csv text/plain application/vnd.ms-excel application/csv].freeze + MAX_PDF_SIZE = 25.megabytes + ALLOWED_CSV_MIME_TYPES = %w[text/csv text/plain application/vnd.ms-excel application/csv].freeze + ALLOWED_PDF_MIME_TYPES = %w[application/pdf].freeze - TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport].freeze + DOCUMENT_TYPES = %w[bank_statement credit_card_statement investment_statement financial_document contract other].freeze + + TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport PdfImport].freeze SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative] SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze @@ -134,6 +138,14 @@ class Import < ApplicationRecord [] end + # Returns false for import types that don't need CSV column mapping (e.g., PdfImport). + # Override in subclasses that handle data extraction differently. + def requires_csv_workflow? + true + end + + # Subclasses that require CSV workflow must override this. + # Non-CSV imports (e.g., PdfImport) can return []. def column_keys raise NotImplementedError, "Subclass must implement column_keys" end diff --git a/app/models/pdf_import.rb b/app/models/pdf_import.rb new file mode 100644 index 000000000..8b25e8bfa --- /dev/null +++ b/app/models/pdf_import.rb @@ -0,0 +1,110 @@ +class PdfImport < Import + has_one_attached :pdf_file + + validates :document_type, inclusion: { in: DOCUMENT_TYPES }, allow_nil: true + + 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 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? + end + + def cleaned? + ai_processed? + end + + def publishable? + false + end + + def column_keys + [] + end + + def requires_csv_workflow? + false + end + + def pdf_file_content + return nil unless pdf_file.attached? + + pdf_file.download + end +end diff --git a/app/models/provider/llm_concept.rb b/app/models/provider/llm_concept.rb index dbd6f0458..5faf233dd 100644 --- a/app/models/provider/llm_concept.rb +++ b/app/models/provider/llm_concept.rb @@ -13,6 +13,16 @@ module Provider::LlmConcept raise NotImplementedError, "Subclasses must implement #auto_detect_merchants" end + PdfProcessingResult = Data.define(:summary, :document_type, :extracted_data) + + def supports_pdf_processing? + false + end + + def process_pdf(pdf_content:, family: nil) + raise NotImplementedError, "Provider does not support PDF processing" + end + ChatMessage = Data.define(:id, :output_text) ChatStreamChunk = Data.define(:type, :data, :usage) ChatResponse = Data.define(:id, :model, :messages, :function_requests) diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index 9ba1d23b0..08ac224f9 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -8,6 +8,9 @@ class Provider::Openai < Provider DEFAULT_OPENAI_MODEL_PREFIXES = %w[gpt-4 gpt-5 o1 o3] DEFAULT_MODEL = "gpt-4.1" + # Models that support PDF/vision input (not all OpenAI models have vision capabilities) + VISION_CAPABLE_MODEL_PREFIXES = %w[gpt-4o gpt-4-turbo gpt-4.1 gpt-5 o1 o3].freeze + # Returns the effective model that would be used by the provider # Uses the same logic as Provider::Registry and the initializer def self.effective_model @@ -18,6 +21,7 @@ class Provider::Openai < Provider def initialize(access_token, uri_base: nil, model: nil) client_options = { access_token: access_token } client_options[:uri_base] = uri_base if uri_base.present? + client_options[:request_timeout] = ENV.fetch("OPENAI_REQUEST_TIMEOUT", 60).to_i @client = ::OpenAI::Client.new(**client_options) @uri_base = uri_base @@ -112,6 +116,65 @@ class Provider::Openai < Provider end end + # Can be disabled via ENV for OpenAI-compatible endpoints that don't support vision + # Only vision-capable models (gpt-4o, gpt-4-turbo, gpt-4.1, etc.) support PDF input + def supports_pdf_processing? + return false unless ENV.fetch("OPENAI_SUPPORTS_PDF_PROCESSING", "true").to_s.downcase.in?(%w[true 1 yes]) + + # Custom providers manage their own model capabilities + return true if custom_provider? + + # Check if the configured model supports vision/PDF input + VISION_CAPABLE_MODEL_PREFIXES.any? { |prefix| @default_model.start_with?(prefix) } + end + + def process_pdf(pdf_content:, model: "", family: nil) + raise "Model does not support PDF/vision processing" unless supports_pdf_processing? + + with_provider_response do + effective_model = model.presence || @default_model + + trace = create_langfuse_trace( + name: "openai.process_pdf", + input: { pdf_size: pdf_content&.bytesize } + ) + + result = PdfProcessor.new( + client, + model: effective_model, + pdf_content: pdf_content, + custom_provider: custom_provider?, + langfuse_trace: trace, + family: family + ).process + + trace&.update(output: result.to_h) + + result + end + end + + def extract_bank_statement(pdf_content:, model: "", family: nil) + with_provider_response do + effective_model = model.presence || @default_model + + trace = create_langfuse_trace( + name: "openai.extract_bank_statement", + input: { pdf_size: pdf_content&.bytesize } + ) + + result = BankStatementExtractor.new( + client: client, + pdf_content: pdf_content, + model: effective_model + ).extract + + trace&.update(output: { transaction_count: result[:transactions].size }) + + result + end + end + def chat_response( prompt, model:, diff --git a/app/models/provider/openai/bank_statement_extractor.rb b/app/models/provider/openai/bank_statement_extractor.rb new file mode 100644 index 000000000..59456d80b --- /dev/null +++ b/app/models/provider/openai/bank_statement_extractor.rb @@ -0,0 +1,213 @@ +class Provider::Openai::BankStatementExtractor + MAX_CHARS_PER_CHUNK = 3000 + attr_reader :client, :pdf_content, :model + + def initialize(client:, pdf_content:, model:) + @client = client + @pdf_content = pdf_content + @model = model + end + + def extract + pages = extract_pages_from_pdf + raise Provider::Openai::Error, "Could not extract text from PDF" if pages.empty? + + chunks = build_chunks(pages) + Rails.logger.info("BankStatementExtractor: Processing #{chunks.size} chunk(s) from #{pages.size} page(s)") + + all_transactions = [] + metadata = {} + + chunks.each_with_index do |chunk, index| + Rails.logger.info("BankStatementExtractor: Processing chunk #{index + 1}/#{chunks.size}") + result = process_chunk(chunk, index == 0) + + # Tag transactions with chunk index for deduplication + tagged_transactions = (result[:transactions] || []).map { |t| t.merge(chunk_index: index) } + all_transactions.concat(tagged_transactions) + + if index == 0 + metadata = { + account_holder: result[:account_holder], + account_number: result[:account_number], + bank_name: result[:bank_name], + opening_balance: result[:opening_balance], + closing_balance: result[:closing_balance], + period: result[:period] + } + end + + if result[:closing_balance].present? + metadata[:closing_balance] = result[:closing_balance] + end + if result.dig(:period, :end_date).present? + metadata[:period] ||= {} + metadata[:period][:end_date] = result.dig(:period, :end_date) + end + end + + { + transactions: deduplicate_transactions(all_transactions), + period: metadata[:period] || {}, + account_holder: metadata[:account_holder], + account_number: metadata[:account_number], + bank_name: metadata[:bank_name], + opening_balance: metadata[:opening_balance], + closing_balance: metadata[:closing_balance] + } + end + + private + + def extract_pages_from_pdf + return [] if pdf_content.blank? + + reader = PDF::Reader.new(StringIO.new(pdf_content)) + reader.pages.map(&:text).reject(&:blank?) + rescue => e + Rails.logger.error("Failed to extract text from PDF: #{e.message}") + [] + end + + def build_chunks(pages) + chunks = [] + current_chunk = [] + current_size = 0 + + pages.each do |page_text| + if page_text.length > MAX_CHARS_PER_CHUNK + chunks << current_chunk.join("\n\n") if current_chunk.any? + current_chunk = [] + current_size = 0 + chunks << page_text + next + end + + if current_size + page_text.length > MAX_CHARS_PER_CHUNK && current_chunk.any? + chunks << current_chunk.join("\n\n") + current_chunk = [] + current_size = 0 + end + + current_chunk << page_text + current_size += page_text.length + end + + chunks << current_chunk.join("\n\n") if current_chunk.any? + chunks + end + + def process_chunk(text, is_first_chunk) + params = { + model: model, + messages: [ + { role: "system", content: is_first_chunk ? instructions_with_metadata : instructions_transactions_only }, + { role: "user", content: "Extract transactions:\n\n#{text}" } + ], + response_format: { type: "json_object" } + } + + response = client.chat(parameters: params) + content = response.dig("choices", 0, "message", "content") + + raise Provider::Openai::Error, "No response from AI" if content.blank? + + parsed = parse_json_response(content) + + { + transactions: normalize_transactions(parsed["transactions"] || []), + period: { + start_date: parsed.dig("statement_period", "start_date"), + end_date: parsed.dig("statement_period", "end_date") + }, + account_holder: parsed["account_holder"], + account_number: parsed["account_number"], + bank_name: parsed["bank_name"], + opening_balance: parsed["opening_balance"], + closing_balance: parsed["closing_balance"] + } + end + + def parse_json_response(content) + cleaned = content.gsub(%r{^```json\s*}i, "").gsub(/```\s*$/, "").strip + JSON.parse(cleaned) + rescue JSON::ParserError => e + Rails.logger.error("BankStatementExtractor JSON parse error: #{e.message} (content_length=#{content.to_s.bytesize})") + { "transactions" => [] } + end + + def deduplicate_transactions(transactions) + # Deduplicates transactions that appear in consecutive chunks (chunking artifacts). + # + # KNOWN LIMITATION: Legitimate duplicate transactions (same date, amount, merchant) + # that happen to appear in adjacent chunks will be incorrectly deduplicated. + # This is an acceptable trade-off since chunking artifacts are more common than + # true same-day duplicates at chunk boundaries. Transactions within the same + # chunk are always preserved regardless of similarity. + seen = Set.new + transactions.select do |t| + # Create key without chunk_index for deduplication + key = [ t[:date], t[:amount], t[:name], t[:chunk_index] ] + + # Check if we've seen this exact transaction in a different chunk + duplicate = seen.any? do |prev_key| + prev_key[0..2] == key[0..2] && (prev_key[3] - key[3]).abs <= 1 + end + + seen << key + !duplicate + end.map { |t| t.except(:chunk_index) } + end + + def normalize_transactions(transactions) + transactions.map do |txn| + { + date: parse_date(txn["date"]), + amount: parse_amount(txn["amount"]), + name: txn["description"] || txn["name"] || txn["merchant"], + category: infer_category(txn), + notes: txn["reference"] || txn["notes"] + } + end.compact + end + + def parse_date(date_str) + return nil if date_str.blank? + + Date.parse(date_str).strftime("%Y-%m-%d") + rescue ArgumentError + nil + end + + def parse_amount(amount) + return nil if amount.nil? + + if amount.is_a?(Numeric) + amount.to_f + else + amount.to_s.gsub(/[^0-9.\-]/, "").to_f + end + end + + def infer_category(txn) + txn["category"] || txn["type"] + end + + def instructions_with_metadata + <<~INSTRUCTIONS.strip + Extract bank statement data as JSON. Return: + {"bank_name":"...","account_holder":"...","account_number":"last 4 digits","statement_period":{"start_date":"YYYY-MM-DD","end_date":"YYYY-MM-DD"},"opening_balance":0.00,"closing_balance":0.00,"transactions":[{"date":"YYYY-MM-DD","description":"...","amount":-0.00}]} + + Rules: Negative amounts for debits/expenses, positive for credits/deposits. Dates as YYYY-MM-DD. Extract ALL transactions. JSON only, no markdown. + INSTRUCTIONS + end + + def instructions_transactions_only + <<~INSTRUCTIONS.strip + Extract transactions from bank statement text as JSON. Return: + {"transactions":[{"date":"YYYY-MM-DD","description":"...","amount":-0.00}]} + + Rules: Negative amounts for debits/expenses, positive for credits/deposits. Dates as YYYY-MM-DD. Extract ALL transactions. JSON only, no markdown. + INSTRUCTIONS + end +end diff --git a/app/models/provider/openai/pdf_processor.rb b/app/models/provider/openai/pdf_processor.rb new file mode 100644 index 000000000..b99caa77c --- /dev/null +++ b/app/models/provider/openai/pdf_processor.rb @@ -0,0 +1,265 @@ +class Provider::Openai::PdfProcessor + include Provider::Openai::Concerns::UsageRecorder + + attr_reader :client, :model, :pdf_content, :custom_provider, :langfuse_trace, :family + + def initialize(client, model: "", pdf_content: nil, custom_provider: false, langfuse_trace: nil, family: nil) + @client = client + @model = model + @pdf_content = pdf_content + @custom_provider = custom_provider + @langfuse_trace = langfuse_trace + @family = family + end + + def process + span = langfuse_trace&.span(name: "process_pdf_api_call", input: { + model: model.presence || Provider::Openai::DEFAULT_MODEL, + pdf_size: pdf_content&.bytesize + }) + + # Try text extraction first (works with all models) + # Fall back to vision API with images if text extraction fails (for scanned PDFs) + response = begin + process_with_text_extraction + rescue Provider::Openai::Error => e + Rails.logger.warn("Text extraction failed: #{e.message}, trying vision API with images") + process_with_vision + end + + span&.end(output: response.to_h) + response + rescue => e + span&.end(output: { error: e.message }, level: "ERROR") + raise + end + + def instructions + <<~INSTRUCTIONS.strip + You are a financial document analysis assistant. Your job is to analyze uploaded PDF documents + and provide a structured summary of what the document contains. + + For each document, you must determine: + + 1. **Document Type**: Classify the document as one of the following: + - `bank_statement`: A bank account statement showing transactions, balances, and account activity + - `credit_card_statement`: A credit card statement showing charges, payments, and balances + - `investment_statement`: An investment/brokerage statement showing holdings, trades, or portfolio performance + - `financial_document`: General financial documents like tax forms, receipts, invoices, or financial reports + - `contract`: Legal agreements, loan documents, terms of service, or policy documents + - `other`: Any document that doesn't fit the above categories + + 2. **Summary**: Provide a concise summary of the document that includes: + - The issuing institution or company name (if identifiable) + - The date range or statement period (if applicable) + - Key financial figures (account balances, total transactions, etc.) + - The account holder's name (if visible, use "Account Holder" if redacted) + - Any notable items or important information + + 3. **Extracted Data**: If the document is a statement with transactions, extract key metadata: + - Number of transactions (if countable) + - Statement period (start and end dates) + - Opening and closing balances (if visible) + - Currency used + + IMPORTANT GUIDELINES: + - Be factual and precise - only report what you can clearly see in the document + - If information is unclear or redacted, note it as "not clearly visible" or "redacted" + - Do NOT make assumptions about data you cannot see + - For statements with many transactions, provide a count rather than listing each one + - Focus on providing actionable information that helps the user understand what they uploaded + - If the document is unreadable or the PDF is corrupted, indicate this clearly + + Respond with ONLY valid JSON in this exact format (no markdown code blocks, no other text): + { + "document_type": "bank_statement|credit_card_statement|investment_statement|financial_document|contract|other", + "summary": "A clear, concise summary of the document contents...", + "extracted_data": { + "institution_name": "Name of bank/company or null", + "statement_period_start": "YYYY-MM-DD or null", + "statement_period_end": "YYYY-MM-DD or null", + "transaction_count": number or null, + "opening_balance": number or null, + "closing_balance": number or null, + "currency": "USD/EUR/etc or null", + "account_holder": "Name or null" + } + } + INSTRUCTIONS + end + + private + + PdfProcessingResult = Provider::LlmConcept::PdfProcessingResult + + def process_with_text_extraction + effective_model = model.presence || Provider::Openai::DEFAULT_MODEL + + # Extract text from PDF using pdf-reader gem + pdf_text = extract_text_from_pdf + raise Provider::Openai::Error, "Could not extract text from PDF" if pdf_text.blank? + + # Truncate if too long (max ~100k chars to stay within token limits) + pdf_text = pdf_text.truncate(100_000) if pdf_text.length > 100_000 + + params = { + model: effective_model, + messages: [ + { role: "system", content: instructions }, + { + role: "user", + content: "Please analyze the following document text and provide a structured summary:\n\n#{pdf_text}" + } + ], + response_format: { type: "json_object" } + } + + response = client.chat(parameters: params) + + Rails.logger.info("Tokens used to process PDF: #{response.dig("usage", "total_tokens")}") + + record_usage( + effective_model, + response.dig("usage"), + operation: "process_pdf", + metadata: { pdf_size: pdf_content&.bytesize } + ) + + parse_response_generic(response) + end + + def extract_text_from_pdf + return nil if pdf_content.blank? + + reader = PDF::Reader.new(StringIO.new(pdf_content)) + text_parts = [] + + reader.pages.each_with_index do |page, index| + text_parts << "--- Page #{index + 1} ---" + text_parts << page.text + end + + text_parts.join("\n\n") + rescue => e + Rails.logger.error("Failed to extract text from PDF: #{e.message}") + nil + end + + def process_with_vision + effective_model = model.presence || Provider::Openai::DEFAULT_MODEL + + # Convert PDF to images using pdftoppm + images_base64 = convert_pdf_to_images + raise Provider::Openai::Error, "Could not convert PDF to images" if images_base64.blank? + + # Build message content with images (max 5 pages to avoid token limits) + content = [] + images_base64.first(5).each do |img_base64| + content << { + type: "image_url", + image_url: { + url: "data:image/png;base64,#{img_base64}", + detail: "low" + } + } + end + content << { + type: "text", + text: "Please analyze this PDF document (#{images_base64.size} pages total, showing first #{[ images_base64.size, 5 ].min}) and respond with valid JSON only." + } + + # Note: response_format is not compatible with vision, so we ask for JSON in the prompt + params = { + model: effective_model, + messages: [ + { role: "system", content: instructions + "\n\nIMPORTANT: Respond with valid JSON only, no markdown or other formatting." }, + { role: "user", content: content } + ], + max_tokens: 4096 + } + + response = client.chat(parameters: params) + + Rails.logger.info("Tokens used to process PDF via vision: #{response.dig("usage", "total_tokens")}") + + record_usage( + effective_model, + response.dig("usage"), + operation: "process_pdf_vision", + metadata: { pdf_size: pdf_content&.bytesize, pages: images_base64.size } + ) + + parse_response_generic(response) + end + + def convert_pdf_to_images + return [] if pdf_content.blank? + + Dir.mktmpdir do |tmpdir| + pdf_path = File.join(tmpdir, "input.pdf") + File.binwrite(pdf_path, pdf_content) + + # Convert PDF to PNG images using pdftoppm + output_prefix = File.join(tmpdir, "page") + system("pdftoppm", "-png", "-r", "150", pdf_path, output_prefix) + + # Read all generated images + image_files = Dir.glob(File.join(tmpdir, "page-*.png")).sort + image_files.map do |img_path| + Base64.strict_encode64(File.binread(img_path)) + end + end + rescue => e + Rails.logger.error("Failed to convert PDF to images: #{e.message}") + [] + end + + def parse_response_generic(response) + raw = response.dig("choices", 0, "message", "content") + parsed = parse_json_flexibly(raw) + + build_result(parsed) + end + + def build_result(parsed) + PdfProcessingResult.new( + summary: parsed["summary"], + document_type: normalize_document_type(parsed["document_type"]), + extracted_data: parsed["extracted_data"] || {} + ) + end + + def normalize_document_type(doc_type) + return "other" if doc_type.blank? + + normalized = doc_type.to_s.strip.downcase.gsub(/\s+/, "_") + Import::DOCUMENT_TYPES.include?(normalized) ? normalized : "other" + end + + def parse_json_flexibly(raw) + return {} if raw.blank? + + # Try direct parse first + JSON.parse(raw) + rescue JSON::ParserError + # Try to extract JSON from markdown code blocks + if raw =~ /```(?:json)?\s*(\{[\s\S]*?\})\s*```/m + begin + return JSON.parse($1) + rescue JSON::ParserError + # Continue to next strategy + end + end + + # Try to find any JSON object + if raw =~ /(\{[\s\S]*\})/m + begin + return JSON.parse($1) + rescue JSON::ParserError + # Fall through to error + end + end + + raise Provider::Openai::Error, "Could not parse JSON from PDF processing response: #{raw.truncate(200)}" + end +end diff --git a/app/views/import/uploads/show.html.erb b/app/views/import/uploads/show.html.erb index 7227ff352..338654578 100644 --- a/app/views/import/uploads/show.html.erb +++ b/app/views/import/uploads/show.html.erb @@ -44,7 +44,7 @@

- <%= form.file_field :csv_file, class: "hidden", "data-auto-submit-form-target": "auto", "data-file-upload-target": "input", "data-drag-and-drop-import-target": "input" %> + <%= form.file_field :import_file, class: "hidden", "data-auto-submit-form-target": "auto", "data-file-upload-target": "input", "data-drag-and-drop-import-target": "input" %> diff --git a/app/views/imports/_pdf_import.html.erb b/app/views/imports/_pdf_import.html.erb new file mode 100644 index 000000000..f2b1ea969 --- /dev/null +++ b/app/views/imports/_pdf_import.html.erb @@ -0,0 +1,84 @@ +<%# locals: (import:) %> + +
+
+ <% if import.importing? || import.pending? %> +
+ <%= icon "loader", class: "animate-pulse" %> +
+ +
+

<%= t("imports.pdf_import.processing_title") %>

+

<%= t("imports.pdf_import.processing_description") %>

+
+ +
+ <%= render DS::Link.new(text: t("imports.pdf_import.check_status"), href: import_path(import), variant: "primary", full_width: true) %> + <%= render DS::Link.new(text: t("imports.pdf_import.back_to_dashboard"), href: root_path, variant: "secondary", full_width: true) %> +
+ + <% elsif import.failed? %> +
+ <%= icon "x", class: "text-destructive" %> +
+ +
+

<%= t("imports.pdf_import.failed_title") %>

+

<%= t("imports.pdf_import.failed_description") %>

+ <% if import.error.present? %> +

<%= import.error %>

+ <% end %> +
+ +
+ <%= render DS::Link.new(text: t("imports.pdf_import.try_again"), href: new_import_path, variant: "primary", full_width: true) %> + <%= button_to t("imports.pdf_import.delete_import"), import_path(import), method: :delete, class: "btn btn--secondary w-full" %> +
+ + <% elsif import.complete? && import.ai_processed? %> +
+ <%= icon "check", class: "text-success" %> +
+ +
+

<%= t("imports.pdf_import.complete_title") %>

+

<%= t("imports.pdf_import.complete_description") %>

+
+ +
+
+

<%= t("imports.pdf_import.document_type_label") %>

+

+ <%= t("imports.document_types.#{import.document_type}") %> +

+
+ +
+

<%= t("imports.pdf_import.summary_label") %>

+

+ <%= import.ai_summary %> +

+
+
+ +
+

<%= t("imports.pdf_import.email_sent_notice") %>

+
+ +
+ <%= render DS::Link.new(text: t("imports.pdf_import.back_to_imports"), href: imports_path, variant: "primary", full_width: true) %> + <%= button_to t("imports.pdf_import.delete_import"), import_path(import), method: :delete, class: "btn btn--secondary w-full" %> +
+ + <% else %> +
+

<%= t("imports.pdf_import.unknown_state_title") %>

+

<%= t("imports.pdf_import.unknown_state_description") %>

+
+ +
+ <%= render DS::Link.new(text: t("imports.pdf_import.back_to_imports"), href: imports_path, variant: "primary", full_width: true) %> +
+ <% end %> +
+
diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index d6910c0ff..91fc9f5ac 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -140,6 +140,35 @@ <%= render "shared/ruler" %> <% end %> + + <% if (params[:type].nil? || params[:type] == "PdfImport") && Provider::Registry.get_provider(:openai)&.supports_pdf_processing? %> +
  • + <%= styled_form_with url: imports_path, scope: :import, multipart: true, class: "w-full" do |form| %> + <%= form.hidden_field :type, value: "PdfImport" %> + + <% end %> + + <%= render "shared/ruler" %> +
  • + <% end %> <% end %> diff --git a/app/views/imports/show.html.erb b/app/views/imports/show.html.erb index ffa552975..d4863585b 100644 --- a/app/views/imports/show.html.erb +++ b/app/views/imports/show.html.erb @@ -2,9 +2,11 @@ <%= render "imports/nav", import: @import %> <% end %> -<%= content_for :previous_path, import_confirm_path(@import) %> +<%= content_for :previous_path, @import.is_a?(PdfImport) ? imports_path : import_confirm_path(@import) %> -<% if @import.importing? %> +<% if @import.is_a?(PdfImport) %> + <%= render "imports/pdf_import", import: @import %> +<% elsif @import.importing? %> <%= render "imports/importing", import: @import %> <% elsif @import.complete? %> <%= render "imports/success", import: @import %> diff --git a/app/views/pdf_import_mailer/next_steps.html.erb b/app/views/pdf_import_mailer/next_steps.html.erb new file mode 100644 index 000000000..595cbcb59 --- /dev/null +++ b/app/views/pdf_import_mailer/next_steps.html.erb @@ -0,0 +1,30 @@ +

    <%= t(".greeting", name: @user.display_name) %>

    + +

    <%= t(".intro", product: product_name) %>

    + +

    <%= t(".document_type_label") %>

    +

    <%= @pdf_import.document_type.present? ? t("imports.document_types.#{@pdf_import.document_type}") : t("imports.document_types.other") %>

    + +

    <%= t(".summary_label") %>

    +

    <%= @pdf_import.ai_summary %>

    + +<% if @pdf_import.document_type.in?(%w[bank_statement credit_card_statement investment_statement]) %> +

    <%= t(".transactions_note") %>

    +<% else %> +

    <%= t(".document_stored_note") %>

    +<% end %> + +

    <%= t(".next_steps_label") %>

    +

    <%= t(".next_steps_intro") %>

    + +
      + <% if @pdf_import.document_type.in?(%w[bank_statement credit_card_statement investment_statement]) %> +
    • <%= t(".option_extract_transactions") %>
    • + <% end %> +
    • <%= t(".option_keep_reference") %>
    • +
    • <%= t(".option_delete") %>
    • +
    + +<%= link_to t(".view_import_button"), @import_url, class: "button" %> + + diff --git a/app/views/pdf_import_mailer/next_steps.text.erb b/app/views/pdf_import_mailer/next_steps.text.erb new file mode 100644 index 000000000..add337d78 --- /dev/null +++ b/app/views/pdf_import_mailer/next_steps.text.erb @@ -0,0 +1,28 @@ +<%= t(".greeting", name: @user.display_name) %> + +<%= t(".intro", product: product_name) %> + +<%= t(".document_type_label") %> +<%= @pdf_import.document_type ? t("imports.document_types.#{@pdf_import.document_type}") : t("imports.document_types.unknown") %> + +<%= t(".summary_label") %> +<%= @pdf_import.ai_summary %> + +<% if @pdf_import.document_type.in?(%w[bank_statement credit_card_statement investment_statement]) %> +<%= t(".transactions_note") %> +<% else %> +<%= t(".document_stored_note") %> +<% end %> + +<%= t(".next_steps_label") %> +<%= t(".next_steps_intro") %> + +<% if @pdf_import.document_type.in?(%w[bank_statement credit_card_statement investment_statement]) %> +- <%= t(".option_extract_transactions") %> +<% end %> +- <%= t(".option_keep_reference") %> +- <%= t(".option_delete") %> + +<%= t(".view_import_button") %>: <%= @import_url %> + +<%= t(".footer_note") %> diff --git a/app/views/transactions/_list.html.erb b/app/views/transactions/_list.html.erb index 1b0589e2a..b3f761065 100644 --- a/app/views/transactions/_list.html.erb +++ b/app/views/transactions/_list.html.erb @@ -7,7 +7,7 @@ <%= form_with url: imports_path, method: :post, class: "hidden", data: { drag_and_drop_import_target: "form" } do |f| %> <%= f.hidden_field "import[type]", value: "TransactionImport" %> - <%= f.file_field "import[csv_file]", class: "hidden", data: { drag_and_drop_import_target: "input" }, accept: ".csv" %> + <%= f.file_field "import[import_file]", class: "hidden", data: { drag_and_drop_import_target: "input" }, accept: ".csv" %> <% end %> <%= render "imports/drag_drop_overlay", title: t(".drag_drop_title"), subtitle: t(".drag_drop_subtitle") %> diff --git a/config/locales/mailers/pdf_import_mailer/en.yml b/config/locales/mailers/pdf_import_mailer/en.yml new file mode 100644 index 000000000..1399d306b --- /dev/null +++ b/config/locales/mailers/pdf_import_mailer/en.yml @@ -0,0 +1,5 @@ +--- +en: + pdf_import_mailer: + next_steps: + subject: "Your PDF document has been analyzed - %{product}" diff --git a/config/locales/views/imports/en.yml b/config/locales/views/imports/en.yml index cd8fb8bd6..a40f3cbf0 100644 --- a/config/locales/views/imports/en.yml +++ b/config/locales/views/imports/en.yml @@ -102,12 +102,51 @@ en: import_portfolio: Import investments import_rules: Import rules import_transactions: Import transactions + import_pdf: Import PDF document + import_pdf_description: AI-powered document analysis resume: Resume %{type} sources: Sources title: New CSV Import + create: + file_too_large: File is too large. Maximum size is %{max_size}MB. + invalid_file_type: Invalid file type. Please upload a CSV file. + csv_uploaded: CSV uploaded successfully. + 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. + show: + finalize_upload: Please finalize your file upload. + finalize_mappings: Please finalize your mappings before proceeding. ready: description: Here's a summary of the new items that will be added to your account once you publish this import. title: Confirm your import data errors: custom_column_requires_inflow: "Custom column imports require an inflow column to be selected" + document_types: + bank_statement: Bank Statement + credit_card_statement: Credit Card Statement + investment_statement: Investment Statement + financial_document: Financial Document + contract: Contract + other: Other Document + unknown: Unknown Document + pdf_import: + processing_title: Processing your PDF + processing_description: We're analyzing your document using AI. This may take a moment. You'll receive an email when the analysis is complete. + check_status: Check status + back_to_dashboard: Back to dashboard + failed_title: Processing failed + failed_description: We were unable to process your PDF document. Please try again or contact support. + try_again: Try again + delete_import: Delete import + complete_title: Document analyzed + complete_description: We've analyzed your PDF and here's what we found. + document_type_label: Document Type + summary_label: Summary + email_sent_notice: An email has been sent to you with next steps. + back_to_imports: Back to imports + unknown_state_title: Unknown state + unknown_state_description: This import is in an unexpected state. Please return to imports. + processing_failed_with_message: "%{message}" + processing_failed_generic: "Processing failed: %{error}" diff --git a/config/locales/views/pdf_import_mailer/en.yml b/config/locales/views/pdf_import_mailer/en.yml new file mode 100644 index 000000000..5298e9e32 --- /dev/null +++ b/config/locales/views/pdf_import_mailer/en.yml @@ -0,0 +1,17 @@ +--- +en: + pdf_import_mailer: + next_steps: + greeting: "Hi %{name}," + intro: "We've finished analyzing the PDF document you uploaded to %{product}." + document_type_label: Document Type + summary_label: AI Summary + transactions_note: This document appears to contain transactions. You can extract and review them now. + document_stored_note: This document has been stored for your reference. It can be used to provide context in future AI conversations. + next_steps_label: What's Next? + next_steps_intro: "You have several options:" + option_extract_transactions: Extract transactions from this statement + option_keep_reference: Keep this document for reference in future AI conversations + option_delete: Delete this import if you no longer need it + view_import_button: View Import Details + footer_note: This is an automated message. Please do not reply directly to this email. diff --git a/db/migrate/20260116100000_add_pdf_import_support.rb b/db/migrate/20260116100000_add_pdf_import_support.rb new file mode 100644 index 000000000..f9d561ee9 --- /dev/null +++ b/db/migrate/20260116100000_add_pdf_import_support.rb @@ -0,0 +1,6 @@ +class AddPdfImportSupport < ActiveRecord::Migration[7.2] + def change + add_column :imports, :ai_summary, :text + add_column :imports, :document_type, :string + end +end diff --git a/db/migrate/20260129200129_add_extracted_data_to_imports.rb b/db/migrate/20260129200129_add_extracted_data_to_imports.rb new file mode 100644 index 000000000..aafea804f --- /dev/null +++ b/db/migrate/20260129200129_add_extracted_data_to_imports.rb @@ -0,0 +1,5 @@ +class AddExtractedDataToImports < ActiveRecord::Migration[7.2] + def change + add_column :imports, :extracted_data, :jsonb + end +end diff --git a/db/schema.rb b/db/schema.rb index 9b7bcb296..3277c7385 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) do +ActiveRecord::Schema[7.2].define(version: 2026_01_29_200129) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -660,6 +660,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) do t.integer "rows_to_skip", default: 0, null: false t.integer "rows_count", default: 0, null: false t.string "amount_type_identifier_value" + t.text "ai_summary" + t.string "document_type" + t.jsonb "extracted_data" t.index ["family_id"], name: "index_imports_on_family_id" end diff --git a/docs/hosting/ai.md b/docs/hosting/ai.md index 23ab1c12f..1106361a1 100644 --- a/docs/hosting/ai.md +++ b/docs/hosting/ai.md @@ -91,6 +91,9 @@ Sure supports any OpenAI-compatible API endpoint. Here are tested providers: ```bash OPENAI_ACCESS_TOKEN=sk-proj-... # No other configuration needed + +# Optional: Request timeout in seconds (default: 60) +# OPENAI_REQUEST_TIMEOUT=60 ``` **Recommended models:** diff --git a/test/controllers/import/uploads_controller_test.rb b/test/controllers/import/uploads_controller_test.rb index 647815d45..6aa3be8b6 100644 --- a/test/controllers/import/uploads_controller_test.rb +++ b/test/controllers/import/uploads_controller_test.rb @@ -26,7 +26,7 @@ class Import::UploadsControllerTest < ActionDispatch::IntegrationTest test "uploads valid csv by file" do patch import_upload_url(@import), params: { import: { - csv_file: file_fixture_upload("imports/valid.csv"), + import_file: file_fixture_upload("imports/valid.csv"), col_sep: "," } } @@ -38,7 +38,7 @@ class Import::UploadsControllerTest < ActionDispatch::IntegrationTest test "invalid csv cannot be uploaded" do patch import_upload_url(@import), params: { import: { - csv_file: file_fixture_upload("imports/invalid.csv"), + import_file: file_fixture_upload("imports/invalid.csv"), col_sep: "," } } diff --git a/test/fixtures/files/imports/sample_bank_statement.pdf b/test/fixtures/files/imports/sample_bank_statement.pdf new file mode 100644 index 0000000000000000000000000000000000000000..377c27b4e2ca394bacc7e72f5f14c1d5cda35506 GIT binary patch literal 52633 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

    a67%)A2k;A?47uojK|4^P9GjoOkn{Gt*p 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 literal 0 HcmV?d00001 diff --git a/test/fixtures/imports.yml b/test/fixtures/imports.yml index b01725327..3e9e185a9 100644 --- a/test/fixtures/imports.yml +++ b/test/fixtures/imports.yml @@ -12,3 +12,15 @@ account: family: dylan_family type: AccountImport status: pending + +pdf: + family: dylan_family + type: PdfImport + status: pending + +pdf_processed: + family: dylan_family + type: PdfImport + status: complete + ai_summary: "This is a bank statement from Chase Bank for the period January 1-31, 2024. It shows 15 transactions with an opening balance of $5,000 and closing balance of $4,500." + document_type: bank_statement diff --git a/test/jobs/process_pdf_job_test.rb b/test/jobs/process_pdf_job_test.rb new file mode 100644 index 000000000..c6374de23 --- /dev/null +++ b/test/jobs/process_pdf_job_test.rb @@ -0,0 +1,35 @@ +require "test_helper" + +class ProcessPdfJobTest < ActiveJob::TestCase + include ActionMailer::TestHelper + + setup do + @import = imports(:pdf) + @family = @import.family + end + + test "skips non-PdfImport imports" do + transaction_import = imports(:transaction) + + ProcessPdfJob.perform_now(transaction_import) + + assert_equal "pending", transaction_import.reload.status + end + + test "skips if PDF not uploaded" do + assert_not @import.pdf_uploaded? + + ProcessPdfJob.perform_now(@import) + + assert_equal "pending", @import.reload.status + end + + test "skips if already processed" do + processed_import = imports(:pdf_processed) + + ProcessPdfJob.perform_now(processed_import) + + # Should not change status since already complete + assert_equal "complete", processed_import.reload.status + end +end diff --git a/test/mailers/pdf_import_mailer_test.rb b/test/mailers/pdf_import_mailer_test.rb new file mode 100644 index 000000000..d5d118b27 --- /dev/null +++ b/test/mailers/pdf_import_mailer_test.rb @@ -0,0 +1,21 @@ +require "test_helper" + +class PdfImportMailerTest < ActionMailer::TestCase + setup do + @user = users(:family_admin) + @pdf_import = imports(:pdf_processed) + end + + test "next_steps email is sent to user" do + mail = PdfImportMailer.with(user: @user, pdf_import: @pdf_import).next_steps + + assert_equal [ @user.email ], mail.to + assert_includes mail.subject, "analyzed" + end + + test "next_steps email contains document summary" do + mail = PdfImportMailer.with(user: @user, pdf_import: @pdf_import).next_steps + + assert_match @pdf_import.ai_summary, mail.body.encoded + end +end diff --git a/test/models/pdf_import_test.rb b/test/models/pdf_import_test.rb new file mode 100644 index 000000000..3138c884b --- /dev/null +++ b/test/models/pdf_import_test.rb @@ -0,0 +1,69 @@ +require "test_helper" + +class PdfImportTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + setup do + @import = imports(:pdf) + @processed_import = imports(:pdf_processed) + end + + test "pdf_uploaded? returns false when no file attached" do + assert_not @import.pdf_uploaded? + end + + test "ai_processed? returns false when no summary present" do + assert_not @import.ai_processed? + end + + test "ai_processed? returns true when summary present" do + assert @processed_import.ai_processed? + end + + test "uploaded? delegates to pdf_uploaded?" do + assert_not @import.uploaded? + end + + test "configured? returns true when AI processed" do + assert @processed_import.configured? + assert_not @import.configured? + end + + test "cleaned? returns true when AI processed" do + assert @processed_import.cleaned? + assert_not @import.cleaned? + end + + test "publishable? always returns false for PDF imports" do + assert_not @import.publishable? + assert_not @processed_import.publishable? + end + + test "column_keys returns empty array" do + assert_equal [], @import.column_keys + end + + test "required_column_keys returns empty array" do + assert_equal [], @import.required_column_keys + end + + test "document_type validates against allowed types" do + @import.document_type = "bank_statement" + assert @import.valid? + + @import.document_type = "invalid_type" + assert_not @import.valid? + assert @import.errors[:document_type].present? + end + + test "document_type allows nil" do + @import.document_type = nil + assert @import.valid? + end + + test "process_with_ai_later enqueues ProcessPdfJob" do + assert_enqueued_with job: ProcessPdfJob, args: [ @import ] do + @import.process_with_ai_later + end + end +end diff --git a/test/system/drag_and_drop_import_test.rb b/test/system/drag_and_drop_import_test.rb index 6a2b94e9b..6a44a3d5b 100644 --- a/test/system/drag_and_drop_import_test.rb +++ b/test/system/drag_and_drop_import_test.rb @@ -20,12 +20,12 @@ class DragAndDropImportTest < ApplicationSystemTestCase execute_script(" var form = document.querySelector('form[action=\"#{imports_path}\"]'); form.classList.remove('hidden'); - var input = document.querySelector('input[name=\"import[csv_file]\"]'); + var input = document.querySelector('input[name=\"import[import_file]\"]'); input.classList.remove('hidden'); input.style.display = 'block'; ") - attach_file "import[csv_file]", file_path + attach_file "import[import_file]", file_path # Submit the form manually since we bypassed the 'drop' event listener which triggers submit find("form[action='#{imports_path}']").evaluate_script("this.requestSubmit()")