diff --git a/app/jobs/process_pdf_job.rb b/app/jobs/process_pdf_job.rb index c577806dc..7d3878232 100644 --- a/app/jobs/process_pdf_job.rb +++ b/app/jobs/process_pdf_job.rb @@ -15,7 +15,8 @@ class ProcessPdfJob < ApplicationJob upload_to_vector_store(pdf_import, document_type: document_type) # For statements with transactions (bank/credit card), extract and generate import rows - if statement_with_transactions?(document_type) + # unless reconciliation already confirmed balances match + if statement_with_transactions?(document_type) && !pdf_import.reconciliation_matched? Rails.logger.info("ProcessPdfJob: Extracting transactions for #{document_type} import #{pdf_import.id}") pdf_import.extract_transactions Rails.logger.info("ProcessPdfJob: Extracted #{pdf_import.extracted_transactions.size} transactions") @@ -23,6 +24,8 @@ class ProcessPdfJob < ApplicationJob pdf_import.generate_rows_from_extracted_data pdf_import.sync_mappings Rails.logger.info("ProcessPdfJob: Generated #{pdf_import.rows_count} import rows") + elsif pdf_import.reconciliation_matched? + Rails.logger.info("ProcessPdfJob: Reconciliation matched for import #{pdf_import.id}, skipping transaction extraction") end # Find the user who created this import (first admin or any user in the family) @@ -32,9 +35,14 @@ class ProcessPdfJob < ApplicationJob pdf_import.send_next_steps_email(user) end - # Statements with extracted rows go to pending for user review/publish - # Other document types are marked complete (no further action needed) - final_status = statement_with_transactions?(document_type) && pdf_import.rows_count > 0 ? :pending : :complete + final_status = if pdf_import.reconciliation_matched? + :complete + elsif statement_with_transactions?(document_type) && pdf_import.rows_count > 0 + :pending + else + :complete + end + pdf_import.update!(status: final_status) rescue StandardError => e sanitized_error = sanitize_error_message(e) diff --git a/app/models/pdf_import.rb b/app/models/pdf_import.rb index 120c74c8e..b6de36d45 100644 --- a/app/models/pdf_import.rb +++ b/app/models/pdf_import.rb @@ -177,10 +177,16 @@ class PdfImport < Import end result = response.data - update!( + + attrs = { ai_summary: result.summary, document_type: result.document_type - ) + } + if result.reconciliation.present? + attrs[:extracted_data] = (result.extracted_data || {}).merge("reconciliation" => result.reconciliation) + end + + update!(attrs) result end @@ -215,6 +221,16 @@ class PdfImport < Import document_type.in?(%w[bank_statement credit_card_statement]) end + def reconciliation_data + extracted_data&.dig("reconciliation") + end + + def reconciliation_matched? + reconciliation_data.present? && + reconciliation_data["performed"] == true && + reconciliation_data["balance_match"] == true + end + def has_extracted_transactions? extracted_data.present? && extracted_data["transactions"].present? end diff --git a/app/models/provider/openai/pdf_processor.rb b/app/models/provider/openai/pdf_processor.rb index 99c768db9..60741cbea 100644 --- a/app/models/provider/openai/pdf_processor.rb +++ b/app/models/provider/openai/pdf_processor.rb @@ -97,6 +97,8 @@ class Provider::Openai::PdfProcessor "performed": true|false, "account_id": "account_id or null", "balance_match": true|false|null, + "statement_closing_balance": number|null, + "synced_closing_balance": number|null, "statement_transaction_count": number|null, "synced_transaction_count": number|null, "matched_count": number|null, diff --git a/app/views/imports/_nav.html.erb b/app/views/imports/_nav.html.erb index 0c9ee5aaa..2b8bda625 100644 --- a/app/views/imports/_nav.html.erb +++ b/app/views/imports/_nav.html.erb @@ -8,10 +8,11 @@ elsif import.is_a?(PdfImport) # PDF imports have a simplified flow: Upload -> Confirm # Upload/Configure/Clean are always complete for processed PDF imports + finalized = import.complete? [ { name: t("imports.steps.upload", default: "Upload"), path: nil, is_complete: import.pdf_uploaded?, step_number: 1 }, - { name: t("imports.steps.configure", default: "Configure"), path: nil, is_complete: import.configured?, step_number: 2 }, - { name: t("imports.steps.clean", default: "Clean"), path: import.configured? ? import_clean_path(import) : nil, is_complete: import.cleaned?, step_number: 3 }, + { name: t("imports.steps.configure", default: "Configure"), path: nil, is_complete: finalized || import.configured?, step_number: 2 }, + { name: t("imports.steps.clean", default: "Clean"), path: (!finalized && import.configured?) ? import_clean_path(import) : nil, is_complete: finalized || import.cleaned?, step_number: 3 }, { name: t("imports.steps.confirm", default: "Confirm"), path: import_path(import), is_complete: import.complete?, step_number: 4 } ] elsif import.is_a?(QifImport) diff --git a/app/views/imports/_pdf_import.html.erb b/app/views/imports/_pdf_import.html.erb index 06f2a6e0e..5a0d2e40c 100644 --- a/app/views/imports/_pdf_import.html.erb +++ b/app/views/imports/_pdf_import.html.erb @@ -1,6 +1,6 @@ <%# locals: (import:) %> -
+
<% if import.pending? && import.rows_count > 0 %> <%# Statement with rows ready for review %> @@ -104,6 +104,77 @@ <%= button_to t("imports.pdf_import.delete_import"), import_path(import), method: :delete, class: "w-full font-medium text-sm px-3 py-2 rounded-lg text-primary bg-surface-inset hover:bg-surface-inset-hover" %>
+ <% elsif import.complete? && import.respond_to?(:reconciliation_matched?) && import.reconciliation_matched? %> + <% recon = import.reconciliation_data %> + +
+ <%= icon "check", class: "text-success" %> +
+ +
+

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

+

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

+
+ +
+
+

<%= 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.reconciliation_details_label") %>

+
+ <% if recon["statement_closing_balance"].present? %> +
+ <%= t("imports.pdf_import.reconciliation_statement_balance") %> + <%= number_to_currency(recon["statement_closing_balance"], unit: import.extracted_data&.dig("currency") || "") %> +
+ <% end %> + <% if recon["synced_closing_balance"].present? %> +
+ <%= t("imports.pdf_import.reconciliation_synced_balance") %> + <%= number_to_currency(recon["synced_closing_balance"], unit: import.extracted_data&.dig("currency") || "") %> +
+ <% end %> +
+ <%= t("imports.pdf_import.reconciliation_balance_match") %> + <%= t("imports.pdf_import.reconciliation_balance_yes") %> +
+ <% if recon["statement_transaction_count"].present? %> +
+ <%= t("imports.pdf_import.reconciliation_statement_txns") %> + <%= recon["statement_transaction_count"] %> +
+ <% end %> + <% if recon["synced_transaction_count"].present? %> +
+ <%= t("imports.pdf_import.reconciliation_synced_txns") %> + <%= recon["synced_transaction_count"] %> +
+ <% end %> + <% if recon["matched_count"].present? %> +
+ <%= t("imports.pdf_import.reconciliation_matched_txns") %> + <%= recon["matched_count"] %> +
+ <% end %> +
+
+ +
+

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

+

<%= import.ai_summary %>

+
+
+ +
+ <%= 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: "w-full font-medium text-sm px-3 py-2 rounded-lg text-primary bg-gray-200 hover:bg-gray-300" %> +
+ <% elsif import.complete? && import.ai_processed? %>
<%= icon "check", class: "text-success" %> diff --git a/config/locales/views/imports/en.yml b/config/locales/views/imports/en.yml index 2a90caf48..222fed34c 100644 --- a/config/locales/views/imports/en.yml +++ b/config/locales/views/imports/en.yml @@ -460,6 +460,16 @@ en: summary_label: Summary email_sent_notice: An email has been sent to you with next steps. back_to_imports: Back to imports + reconciliation_title: Reconciliation report + reconciliation_description: We've compared your statement against synced data and everything matches. + reconciliation_details_label: Reconciliation Details + reconciliation_statement_balance: "Statement closing balance" + reconciliation_synced_balance: "Synced closing balance" + reconciliation_balance_match: Balance match + reconciliation_balance_yes: Yes + reconciliation_statement_txns: Statement transactions + reconciliation_synced_txns: Synced transactions + reconciliation_matched_txns: Matched transactions unknown_state_title: Unknown state unknown_state_description: This import is in an unexpected state. Please return to imports. processing_failed_with_message: "%{message}"