Add display step for reconciliation report

This commit is contained in:
Juan José Mata
2026-04-06 14:50:35 +00:00
parent b3d8ff9002
commit 520648272c
6 changed files with 117 additions and 9 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View File

@@ -1,6 +1,6 @@
<%# locals: (import:) %>
<div class="h-full flex flex-col justify-center items-center">
<div class="min-h-full flex flex-col justify-center items-center py-8">
<div class="space-y-6 max-w-lg w-full">
<% 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" %>
</div>
<% elsif import.complete? && import.respond_to?(:reconciliation_matched?) && import.reconciliation_matched? %>
<% recon = import.reconciliation_data %>
<div class="mx-auto bg-success/10 h-8 w-8 rounded-full flex items-center justify-center">
<%= icon "check", class: "text-success" %>
</div>
<div class="text-center space-y-2">
<h1 class="font-medium text-primary text-center text-3xl"><%= t("imports.pdf_import.reconciliation_title") %></h1>
<p class="text-sm text-secondary"><%= t("imports.pdf_import.reconciliation_description") %></p>
</div>
<div class="bg-container border border-primary rounded-xl p-4 space-y-4">
<div class="space-y-2">
<h2 class="font-medium text-primary"><%= t("imports.pdf_import.document_type_label") %></h2>
<p class="text-sm text-secondary px-3 py-2 bg-gray-500/5 rounded-lg">
<%= t("imports.document_types.#{import.document_type}", default: import.document_type&.humanize || t("imports.pdf_import.unknown_document_type", default: "Unknown")) %>
</p>
</div>
<div class="space-y-2">
<h2 class="font-medium text-primary"><%= t("imports.pdf_import.reconciliation_details_label") %></h2>
<div class="space-y-1 px-3 py-2 bg-gray-500/5 rounded-lg">
<% if recon["statement_closing_balance"].present? %>
<div class="flex justify-between text-sm">
<span class="text-secondary"><%= t("imports.pdf_import.reconciliation_statement_balance") %></span>
<span class="text-primary font-medium privacy-sensitive"><%= number_to_currency(recon["statement_closing_balance"], unit: import.extracted_data&.dig("currency") || "") %></span>
</div>
<% end %>
<% if recon["synced_closing_balance"].present? %>
<div class="flex justify-between text-sm">
<span class="text-secondary"><%= t("imports.pdf_import.reconciliation_synced_balance") %></span>
<span class="text-primary font-medium privacy-sensitive"><%= number_to_currency(recon["synced_closing_balance"], unit: import.extracted_data&.dig("currency") || "") %></span>
</div>
<% end %>
<div class="flex justify-between text-sm">
<span class="text-secondary"><%= t("imports.pdf_import.reconciliation_balance_match") %></span>
<span class="text-success font-medium"><%= t("imports.pdf_import.reconciliation_balance_yes") %></span>
</div>
<% if recon["statement_transaction_count"].present? %>
<div class="flex justify-between text-sm">
<span class="text-secondary"><%= t("imports.pdf_import.reconciliation_statement_txns") %></span>
<span class="text-primary font-medium"><%= recon["statement_transaction_count"] %></span>
</div>
<% end %>
<% if recon["synced_transaction_count"].present? %>
<div class="flex justify-between text-sm">
<span class="text-secondary"><%= t("imports.pdf_import.reconciliation_synced_txns") %></span>
<span class="text-primary font-medium"><%= recon["synced_transaction_count"] %></span>
</div>
<% end %>
<% if recon["matched_count"].present? %>
<div class="flex justify-between text-sm">
<span class="text-secondary"><%= t("imports.pdf_import.reconciliation_matched_txns") %></span>
<span class="text-primary font-medium"><%= recon["matched_count"] %></span>
</div>
<% end %>
</div>
</div>
<div class="space-y-2">
<h2 class="font-medium text-primary"><%= t("imports.pdf_import.summary_label") %></h2>
<p class="text-sm text-secondary px-3 py-2 bg-gray-500/5 rounded-lg whitespace-pre-wrap privacy-sensitive"><%= import.ai_summary %></p>
</div>
</div>
<div class="space-y-2 flex flex-col">
<%= 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" %>
</div>
<% elsif import.complete? && import.ai_processed? %>
<div class="mx-auto bg-success/10 h-8 w-8 rounded-full flex items-center justify-center">
<%= icon "check", class: "text-success" %>

View File

@@ -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}"