Files
sure/app/models/entry.rb
T
Juan José MataandClaude Opus 5 8ca65ffd27 Reconcile PDF statement imports against transactions that already exist (#3105)
* Reset to main, keeping only the account matcher improvements

Backs out the LLM-driven reconciliation work (PR #1382's approach and the two
commits hardening it). That approach compares whole-statement aggregates, which
is all-or-nothing: when 18 of 20 transactions have already synced the totals
disagree, the import proceeds, and 18 duplicates are created. Reconciliation is
a row-level problem and belongs in the import path, where TransactionImport
already solves it via Account::ProviderImportAdapter.

Kept from that work, because it stands on its own:

- AccountMatcher gains a hint-based class-level entry point so callers without
  an AccountStatement row can score against the same rules. The instance path
  used by AccountStatement#assign_account_match is unchanged.
- It also refuses to guess between equally-confident candidates rather than
  letting max_by take whichever the scan reached first. Account names are not
  unique within a family, so that tie was reachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvWSJotDiqP6qSR7qEiC8Q

* Reconcile PDF statement imports against transactions that already exist

Reconciliation is a row-level problem. Comparing whole-statement totals is
all-or-nothing: when 18 of 20 transactions have already synced the totals
disagree, the import proceeds, and 18 duplicates are created. This matches each
extracted transaction against what the account already holds, so only genuinely
new transactions are ever offered for import and the rest are marked reconciled.

TransactionImport#import! already does this for CSV via
Account::ProviderImportAdapter. PdfImport#import! called it zero times and built
one Transaction per row unconditionally -- the only import path in the repo with
no duplicate protection.

Reconciliation state follows Quicken's uncleared / cleared / reconciled, but only
the last state is stored:

- "Cleared" means the institution acknowledged the transaction, which is exactly
  what entries.source and entries.external_id already record. It stays accurate
  on its own, because the adapter stamps both onto a manual entry when a provider
  transaction claims it, so a hand-entered transaction that later appears in a
  download becomes cleared with no extra bookkeeping. Deriving it also keeps it
  non-editable, which is right: it is a fact about provenance, not an opinion.
- "Reconciled" means a statement was matched against the transaction. Nothing can
  derive that, so entries gains reconciled_at and reconciled_by_statement_id. It
  is a judgement, so it can be set and unset, and it survives the statement being
  deleted (the FK nullifies, the timestamp stays).

Matching:

- find_duplicate_transaction grows include_provider_entries, which is what makes
  this work for Provider-backed accounts -- the existing where(external_id: nil)
  filter hid synced transactions from every import path, so this gap affected CSV
  imports equally. Default stays false: provider sync must not claim another
  provider's entry.
- It also grows date_window, because a statement's posting date routinely differs
  by a day or two from the date a provider recorded. Nearest date wins.
- Name is deliberately not matched on: statement descriptions and provider names
  for the same transaction rarely agree. The adapter makes the same choice for
  sync.
- Candidates are built as real Import::Row objects so matching uses the same
  signed_amount and date_iso the import itself would write, rather than a second
  interpretation of signage that could drift.
- Matching is per-account, so with no account assigned every row is offered and
  re-judged on assignment; reassigning also releases the previous account's
  reconciliations.
- A row whose date or amount will not parse is offered for import rather than
  dropped, so nothing disappears silently.
- import! re-checks at publish, since a sync can land between review and publish,
  and new transactions are born reconciled: the statement is their evidence.

Provider-backed accounts are now offered in the import target picker. The
manual-only restriction existed because importing into a synced account would
duplicate what sync brought in, which is precisely what this removes.

Also fixes a bug this uncovered on main: extract_transactions stored the
extractor's symbol-keyed hash, while every reader digs with strings. jsonb keeps
the hash as assigned until reload and ProcessPdfJob never reloads, so
has_extracted_transactions? was false and PDF imports generated zero rows. The
existing tests missed it -- one uses a YAML fixture, the other stubs the
extractor with string keys.

Supersedes #1382. Refs #1379.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvWSJotDiqP6qSR7qEiC8Q

* Fix three review findings in statement reconciliation

All three confirmed against the code before fixing.

Publish-time recheck consumed the same entry twice. import! started its
exclusion list empty, so a statement carrying two same-amount transactions
against an account holding only one would re-match the surviving row against the
entry row generation had already consumed -- silently dropping a genuinely new
transaction instead of creating it. Seed the exclusions with the entries this
statement already reconciled; newly synced entries are still caught, since only
already-reconciled ones are excluded.

A regeneration that emptied the row set left the import stranded. In the normal
upload flow the account is assigned after extraction, so assign_account!
regenerates -- and if everything then reconciled, rows_count went to zero while
status stayed pending. _pdf_import.html.erb renders pending-with-no-rows as the
processing screen, and process_with_ai_later cannot restart because
ai_processed? is already true, so the import was stuck with no way forward.
Status now follows the same rule ProcessPdfJob applies after initial processing,
in both directions: no rows completes it, rows returning sends it back to
pending. Guarded by data_committed? so a published import is never reopened.

Unevaluatable rows went only to the Rails log. AGENTS.md asks for
DebugLogEntry.capture on recoverable import failures so they surface in
/settings/debug with structured context. Capture family, account, import,
statement, row number and the raw date/amount that would not parse.

Adds regression coverage for each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvWSJotDiqP6qSR7qEiC8Q

* Fix row regeneration collision and over-broad reconciliation release

test_unit caught one error in 6462 tests, and it was real.

Row regeneration collided on the second call. insert_all! bypasses
ActiveRecord, so the rows association is never populated with what it wrote.
Calling generate_rows_from_extracted_data twice on the same in-memory record --
which assign_account! now does after ProcessPdfJob has already generated once --
made rows.destroy_all clear a stale empty collection, delete nothing, and then
collide on (import_id, source_row_number). Reload before destroying, and reset
the association after inserting so sync_mappings and the view read what was
actually written.

Releasing reconciliations was scoped to the statement, not the account. A
statement is evidence for exactly one account at a time but can back more than
one import, so reassigning an account cleared reconciliations another account
still relied on. Scoped to the account being moved away from; a blank scope
releases nothing, which is correct because nothing is reconciled while no
account is assigned.

The second finding was raised by CodeRabbit. Its other flagged risk -- entries
being marked reconciled before the import is published -- is deliberate and
stays: the statement is the evidence, reconciling is reversible via
unmark_reconciled!, and deferring it to publish would leave a fully reconciled
statement with nothing to publish and therefore nothing ever marked.

Adds regression coverage for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvWSJotDiqP6qSR7qEiC8Q

* Address review nitpicks: model validation, scope placement, mock style, lock-safe DDL

All four checked against the repo's own conventions before applying.

Mirror the check constraint as a model validation. Assigning
reconciled_by_statement without reconciled_at raised StatementInvalid rather
than a validation error. CLAUDE.md Convention 5 asks for exactly this pairing --
the constraint in the database, an ActiveRecord validation for form-friendly
errors.

Stop hijacking the pending-scope comment. The reconciliation scopes were
inserted directly under "Pending transaction scopes", so that header read as
documentation for them and the provider note below read as a continuation of
reconciled_by. Given the reconciliation scopes their own header.

Use OpenStruct for the provider response double, per "Always prefer OpenStruct
when creating mock instances". Verified OpenStruct.new(success?: true) responds
to success?, and ostruct is already a dependency used elsewhere in test/.

Make the migration lock-safe. entries is the largest table in the app: both
indexes now build concurrently, and the check constraint is added unvalidated
then validated separately so VALIDATE takes only SHARE UPDATE EXCLUSIVE instead
of holding ACCESS EXCLUSIVE for a full scan. This follows existing practice --
13 migrations already use disable_ddl_transaction! and 11 use algorithm:
:concurrently, with add_offline_reason_to_securities combining add_column and a
concurrent index in one migration exactly like this. The suggested follow-up
migration for validation was not needed: validating in the same non-
transactional migration gets the same lock behavior without a second file, and
the repo has no validate: false precedent in 400 migrations.

schema.rb is unchanged: a validated constraint and a concurrently-built index
dump identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvWSJotDiqP6qSR7qEiC8Q

* Add the two review-requested regression tests

Covers the invalid-amount half of "malformed rows are offered, not dropped" --
the existing test only exercised an unparseable date. Asserts the raw value is
stored verbatim rather than coerced to 0, so the review step shows the user what
the statement actually said.

Also covers the Entry validation added in f5ba646: assigning
reconciled_by_statement without reconciled_at must fail model validation rather
than reaching chk_entries_reconciled_at_present_when_statement_set and raising
StatementInvalid.

Both requested by CodeRabbit on f5ba646.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvWSJotDiqP6qSR7qEiC8Q

* Assert both reconciliation fields when an account is reassigned

reconciled? only reads reconciled_at, so the test proved the state was cleared
but not that the statement evidence went with it, nor that the sibling account
kept its own. Entry#unmark_reconciled! clears the pair, so assert the pair.

Raised by CodeRabbit on eac82cf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvWSJotDiqP6qSR7qEiC8Q

* Release reconciliation on revert and guard account reassignment

Two correctness findings from review.

Import#revert destroyed the import's own entries but nothing else, so a
statement import left stale evidence behind: entries it had only *matched*
kept reconciled_at and reconciled_by_statement_id pointing at a statement
that no longer claimed them. Worse, a statement that reconciled every line
carries zero rows, so revert returned it to pending with rows_count 0 --
the exact combination the pdf import view renders as the processing screen,
with no way to regenerate rows or re-trigger extraction.

Import#revert now calls two hooks inside its transaction: revert_derived_state!
for state a subclass keeps outside its own rows and entries, and
status_after_revert for where the record lands. The base behavior is unchanged.
PdfImport releases its reconciliations, re-judges every statement line against
what the account actually holds now, and finishes as complete when there is
nothing left to offer.

PdfImport#assign_account! had no guard against an already-published import.
A back-button or replayed PATCH ran release_reconciliations! and
generate_rows_from_extracted_data unconditionally, releasing evidence and
destroying the rows that documented what was published while the created
entries stayed put -- only refresh_status_after_regeneration! checked
data_committed?. It now takes the row lock, refuses when the import has
committed data or a job owns the record, and returns false so the controller
can explain rather than report a save that did not happen. An import that
reconciled every line is still re-targetable: it is complete, but committed
nothing of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvWSJotDiqP6qSR7qEiC8Q

* Show what became of each statement transaction

Row-level matching made the import's outcome invisible. A statement whose
lines were all already on file finishes with rows_count 0 and renders the
generic "Document analyzed" screen, which cannot be told apart from a
statement nothing was extracted from. The user is told the import is done
and nothing else.

Two counts also became wrong rather than merely absent. The ready-for-review
screen labels rows_count as "Transactions Extracted", but after matching that
is the *unmatched* count: a 20-line statement against an account holding 18 of
them read "2 transactions extracted". ready_for_review_description made the
same claim in prose.

Adds a summary dialog at GET /imports/:id/summary, linked from both the
ready-for-review and complete screens, breaking the statement down into what
was found, what was already recorded, what was imported, and what is still
waiting. The counts are derived from the entries rather than stored, so they
stay true if a later sync or edit changes the picture.

Two of them need care. Entries this import creates are born reconciled, so
already_recorded_count has to exclude them or it double-counts what the
account genuinely already had. And publishing does not destroy rows -- they
remain as the record of what was written -- so awaiting_review_count reports
zero once the data is committed rather than repeating rows_count.

The complete screen now explains a fully reconciled import instead of
claiming to have found something, and the extracted count reports the
statement's real size with the matched count beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvWSJotDiqP6qSR7qEiC8Q

* Memoize the import outcome counts

Rendering the summary dialog issued roughly fifteen queries for four
numbers. already_recorded_count, imported_count and awaiting_review_count
are each read two or three times per template, every read is its own
COUNT, reconciled_anything? adds another by calling already_recorded_count
internally, and awaiting_review_count consults data_committed? -- two more
EXISTS queries -- on every invocation.

Memoizing on the model rather than assigning locals in the template fixes
the review screen too, which reads the same counts, and keeps the
arithmetic out of the view.

These report a finished outcome for display. Anything that re-judges the
import recomputes from the entries directly, so a value cached for the life
of the request is what the callers want.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvWSJotDiqP6qSR7qEiC8Q

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-22 03:14:03 +02:00

582 lines
21 KiB
Ruby

class Entry < ApplicationRecord
include Monetizable, Enrichable
TRUTHY_VALUES = [ true, "true", "1", 1 ].freeze
private_constant :TRUTHY_VALUES
attr_accessor :unsplitting
monetize :amount
belongs_to :account
belongs_to :transfer, optional: true
belongs_to :import, optional: true
belongs_to :parent_entry, class_name: "Entry", optional: true
belongs_to :reconciled_by_statement, class_name: "AccountStatement", optional: true
# Mirrors chk_entries_reconciled_at_present_when_statement_set so a direct
# assignment surfaces a validation error rather than a StatementInvalid.
validates :reconciled_at, presence: true, if: -> { reconciled_by_statement_id.present? }
has_many :child_entries, class_name: "Entry", foreign_key: :parent_entry_id, dependent: :destroy
delegated_type :entryable, types: Entryable::TYPES, dependent: :destroy
accepts_nested_attributes_for :entryable
validates :date, :name, :amount, :currency, presence: true
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? }
validates :date, comparison: { greater_than: -> { min_supported_date } }
validates :external_id, uniqueness: { scope: [ :account_id, :source ] }, if: -> { external_id.present? && source.present? }
validate :cannot_unexclude_split_parent
validate :split_child_date_matches_parent
before_destroy :prevent_individual_child_deletion, if: :split_child?
scope :visible, -> {
joins(:account).where(accounts: { status: [ "draft", "active" ] })
}
scope :chronological, -> {
order(
date: :asc,
Arel.sql("CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END") => :asc,
created_at: :asc,
id: :asc
)
}
scope :reverse_chronological, -> {
order(
date: :desc,
Arel.sql("CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END") => :desc,
created_at: :desc,
id: :desc
)
}
# Reconciliation scopes - see AddReconciliationToEntries
scope :reconciled, -> { where.not(reconciled_at: nil) }
scope :unreconciled, -> { where(reconciled_at: nil) }
scope :reconciled_by, ->(statement) { where(reconciled_by_statement_id: statement) }
# Pending transaction scopes - check Transaction.extra for provider pending flags
# Works with any provider that stores pending status in extra["provider_name"]["pending"]
scope :pending, -> {
conditions = Transaction::PENDING_PROVIDERS.map { |p| "(transactions.extra -> '#{p}' ->> 'pending')::boolean = true" }
joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(conditions.join(" OR "))
}
scope :excluding_pending, -> {
# For non-Transaction entries (Trade, Valuation), always include
# For Transaction entries, exclude if any provider marks it pending
where(<<~SQL.squish)
entries.entryable_type != 'Transaction'
OR NOT EXISTS (
SELECT 1 FROM transactions t
WHERE t.id = entries.entryable_id
AND (#{Transaction::PENDING_CHECK_SQL})
)
SQL
}
scope :excluding_split_parents, -> {
where(<<~SQL.squish)
NOT EXISTS (
SELECT 1 FROM entries ce WHERE ce.parent_entry_id = entries.id
)
SQL
}
# Find stale pending transactions (pending for more than X days with no matching posted version)
scope :stale_pending, ->(days: 8) {
pending.where("entries.date < ?", days.days.ago.to_date)
}
# Family-scoped query for Enrichable#clear_ai_cache
def self.family_scope(family)
joins(:account).where(accounts: { family_id: family.id })
end
# Uncategorized, non-transfer transaction entries on draft or active accounts.
# Caller is responsible for scoping to accessible entries before applying this scope.
scope :uncategorized_transactions, -> {
joins(:account)
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(accounts: { status: %w[draft active] })
.where(transactions: { category_id: nil })
.where.not(transactions: { kind: Transaction::TRANSFER_KINDS })
.where(entries: { excluded: false })
}
# Returns uncategorized, non-transfer entries whose name matches the given filter string.
# Used by the Quick Categorize Wizard to preview which transactions a rule would affect.
# @param entries [ActiveRecord::Relation] pre-scoped entries (caller controls authorization)
def self.uncategorized_matching(entries, filter, transaction_type = nil)
sanitized = sanitize_sql_like(filter.gsub(/\s+/, " ").strip)
scope = entries
.uncategorized_transactions
.where("BTRIM(REGEXP_REPLACE(entries.name, '[[:space:]]+', ' ', 'g')) ILIKE ?", "%#{sanitized}%")
scope = case transaction_type
when "income" then scope.where("entries.amount < 0")
when "expense" then scope.where("entries.amount >= 0")
else scope
end
scope.includes(entryable: :merchant).order(entries: { date: :desc }).to_a
end
# Auto-exclude stale pending transactions for an account
# Called during sync to clean up pending transactions that never posted
# @param account [Account] The account to clean up
# @param days [Integer] Number of days after which pending is considered stale (default: 8)
# @return [Integer] Number of entries excluded
def self.auto_exclude_stale_pending(account:, days: 8)
stale_entries = account.entries.stale_pending(days: days).where(excluded: false)
count = stale_entries.count
if count > 0
stale_entries.update_all(excluded: true, updated_at: Time.current)
Rails.logger.info("Auto-excluded #{count} stale pending transaction(s) for account #{account.id} (#{account.name})")
end
count
end
# Retroactively reconcile pending transactions that have a matching posted version
# This handles duplicates created before reconciliation code was deployed
#
# @param account [Account, nil] Specific account to clean up, or nil for all accounts
# @param dry_run [Boolean] If true, only report what would be done without making changes
# @param date_window [Integer] Days to search forward for posted matches (default: 8)
# @param amount_tolerance [Float] Percentage difference allowed for fuzzy matching (default: 0.25)
# @return [Hash] Stats about what was reconciled
def self.reconcile_pending_duplicates(account: nil, dry_run: false, date_window: 8, amount_tolerance: 0.25)
stats = { checked: 0, reconciled: 0, details: [] }
not_pending_sql = Transaction::PENDING_PROVIDERS
.map { |p| "(transactions.extra -> '#{p}' ->> 'pending')::boolean IS NOT TRUE" }
.join(" AND ")
# Get pending entries to check
scope = Entry.pending.where(excluded: false)
scope = scope.where(account: account) if account
scope.includes(:account, :entryable).find_each do |pending_entry|
stats[:checked] += 1
acct = pending_entry.account
# PRIORITY 1: Look for posted transaction with EXACT amount match
# CRITICAL: Only search forward in time - posted date must be >= pending date
exact_candidates = acct.entries
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where.not(id: pending_entry.id)
.where(currency: pending_entry.currency)
.where(amount: pending_entry.amount)
.where(date: pending_entry.date..(pending_entry.date + date_window.days)) # Posted must be ON or AFTER pending date
.where(not_pending_sql)
.limit(2) # Only need to know if 0, 1, or 2+ candidates
.to_a # Load limited records to avoid COUNT(*) on .size
# Handle exact match - auto-exclude only if exactly ONE candidate (high confidence)
# Multiple candidates = ambiguous = skip to avoid excluding wrong entry
if exact_candidates.size == 1
posted_match = exact_candidates.first
detail = {
pending_id: pending_entry.id,
pending_name: pending_entry.name,
pending_amount: pending_entry.amount.to_f,
pending_date: pending_entry.date,
posted_id: posted_match.id,
posted_name: posted_match.name,
posted_amount: posted_match.amount.to_f,
posted_date: posted_match.date,
account: acct.name,
match_type: "exact"
}
stats[:details] << detail
stats[:reconciled] += 1
unless dry_run
pending_entry.update!(excluded: true)
Rails.logger.info("Reconciled pending→posted duplicate: excluded entry #{pending_entry.id} (#{pending_entry.name}) matched to #{posted_match.id}")
end
next
end
# PRIORITY 2: If no exact match, try fuzzy amount match for tip adjustments
# Store as SUGGESTION instead of auto-excluding (medium confidence)
pending_amount = pending_entry.amount.abs
min_amount = pending_amount
max_amount = pending_amount * (1 + amount_tolerance)
fuzzy_date_window = 3
candidates = acct.entries
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where.not(id: pending_entry.id)
.where(currency: pending_entry.currency)
.where(date: pending_entry.date..(pending_entry.date + fuzzy_date_window.days)) # Posted ON or AFTER pending
.where("ABS(entries.amount) BETWEEN ? AND ?", min_amount, max_amount)
.where(not_pending_sql)
# Match by name similarity (first 3 words)
name_words = pending_entry.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
if name_words.present?
matching_candidates = candidates.select do |c|
c_words = c.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
name_words == c_words
end
# Only suggest if there's exactly ONE matching candidate
# Multiple matches = ambiguous (e.g., recurring gas station visits) = skip
if matching_candidates.size == 1
fuzzy_match = matching_candidates.first
detail = {
pending_id: pending_entry.id,
pending_name: pending_entry.name,
pending_amount: pending_entry.amount.to_f,
pending_date: pending_entry.date,
posted_id: fuzzy_match.id,
posted_name: fuzzy_match.name,
posted_amount: fuzzy_match.amount.to_f,
posted_date: fuzzy_match.date,
account: acct.name,
match_type: "fuzzy_suggestion"
}
stats[:details] << detail
unless dry_run
# Store suggestion on the pending entry instead of auto-excluding
pending_transaction = pending_entry.entryable
if pending_transaction.is_a?(Transaction)
existing_extra = pending_transaction.extra || {}
unless existing_extra["potential_posted_match"].present?
pending_transaction.update!(
extra: existing_extra.merge(
"potential_posted_match" => {
"entry_id" => fuzzy_match.id,
"reason" => "fuzzy_amount_match",
"posted_amount" => fuzzy_match.amount.to_s,
"confidence" => "medium",
"dismissed" => false,
"detected_at" => Date.current.to_s
}
)
)
Rails.logger.info("Stored duplicate suggestion for entry #{pending_entry.id} (#{pending_entry.name}) → #{fuzzy_match.id}")
end
end
end
elsif matching_candidates.size > 1
Rails.logger.info("Skipping fuzzy reconciliation for #{pending_entry.id} (#{pending_entry.name}): #{matching_candidates.size} ambiguous candidates")
end
end
end
stats
end
def classification
amount.negative? ? "income" : "expense"
end
def lock_saved_attributes!
super
entryable.lock_saved_attributes!
end
def sync_account_later
sync_start_date = [ date_previously_was, date ].compact.min unless destroyed?
account.sync_later(window_start_date: sync_start_date)
end
def entryable_name_short
entryable_type.demodulize.underscore
end
def balance_trend(entries, balances)
Balance::TrendCalculator.new(self, entries, balances).trend
end
def linked?
external_id.present?
end
# Reconciliation state, following the Quicken uncleared / cleared / reconciled
# model. Only the last state is stored -- see AddReconciliationToEntries.
#
# @return [Symbol] :uncleared, :cleared or :reconciled
def reconciliation_state
return :reconciled if reconciled?
return :cleared if cleared?
:uncleared
end
# The institution has acknowledged this transaction: it either arrived from a
# provider, or was entered by hand and later claimed by one (which stamps
# external_id and source -- see Account::ProviderImportAdapter). Derived rather
# than stored so it cannot drift, and deliberately not user-settable: this is a
# fact about where the entry came from, not an opinion about it.
def cleared?
external_id.present? || source.present?
end
# A statement has been matched against this transaction. Unlike cleared?, this
# is a judgement -- made by a statement import, or by the user directly -- so
# it is stored and can be undone.
def reconciled?
reconciled_at.present?
end
# @param statement [AccountStatement, nil] the statement providing the evidence
def mark_reconciled!(statement: nil, at: Time.current)
update!(reconciled_at: at, reconciled_by_statement: statement)
end
def unmark_reconciled!
update!(reconciled_at: nil, reconciled_by_statement: nil)
end
# Checks if entry should be protected from provider sync overwrites.
# This does NOT prevent user from editing - only protects from automated sync.
#
# @return [Boolean] true if entry should be skipped during provider sync
def protected_from_sync?
excluded? || user_modified? || import_locked?
end
# Bulk-marks the entries of the given transactions as user-modified so a
# later provider sync won't overwrite them (issue #1977). Used by merchant
# merge/convert/unlink flows, which reassign merchant_id directly on
# transactions and must protect that manual change from being reverted.
#
# Accepts a Transaction relation (preferred — the selection runs as a
# subquery so large merges/unlinks don't materialize ids or hit SQL
# parameter limits) or an explicit array of ids.
#
# @param transactions [ActiveRecord::Relation, Array<String>] Transactions or their ids
# @return [void]
def self.mark_user_modified_for_transactions!(transactions)
entryable_ids =
if transactions.is_a?(ActiveRecord::Relation)
transactions.select(:id)
else
ids = Array(transactions).compact.uniq
return if ids.empty?
ids
end
where(entryable_type: "Transaction", entryable_id: entryable_ids).update_all(user_modified: true)
end
# Marks entry as user-modified after manual edit.
# Called when user edits any field to prevent provider sync from overwriting.
#
# @return [Boolean] true if successfully marked
def mark_user_modified!
return true if user_modified?
update!(user_modified: true)
end
# Returns the reason this entry is protected from sync, or nil if not protected.
# Priority: excluded > user_modified > import_locked
#
# @return [Symbol, nil] :excluded, :user_modified, :import_locked, or nil
def protection_reason
return :excluded if excluded?
return :user_modified if user_modified?
return :import_locked if import_locked?
nil
end
# Returns array of field names that are locked on entry and entryable.
#
# @return [Array<String>] locked field names
def locked_field_names
entry_keys = locked_attributes&.keys || []
entryable_keys = entryable&.locked_attributes&.keys || []
(entry_keys + entryable_keys).uniq
end
# Returns hash of locked field names to their lock timestamps.
# Combines locked_attributes from both entry and entryable.
# Parses ISO8601 timestamps stored in locked_attributes.
#
# @return [Hash{String => Time}] field name to lock timestamp
def locked_fields_with_timestamps
combined = (locked_attributes || {}).merge(entryable&.locked_attributes || {})
combined.transform_values do |timestamp|
Time.zone.parse(timestamp.to_s) rescue timestamp
end
end
# Clears protection flags so provider sync can update this entry again.
# Clears user_modified, import_locked flags, and all locked_attributes
# on both the entry and its entryable.
#
# @return [void]
def unlock_for_sync!
self.class.transaction do
update!(user_modified: false, import_locked: false, locked_attributes: {})
entryable&.update!(locked_attributes: {})
end
end
def split_parent?
child_entries.exists?
end
def split_child?
parent_entry_id.present?
end
# Splits this entry into child entries. Marks parent as excluded.
#
# @param splits [Array<Hash>] array of { name:, amount:, category_id:, excluded: } hashes
# @return [Array<Entry>] the created child entries
def split!(splits)
total = splits.sum { |s| s[:amount].to_d }
unless total == amount
raise ActiveRecord::RecordInvalid.new(self), "Split amounts must sum to parent amount (expected #{amount}, got #{total})"
end
self.class.transaction do
children = splits.map do |split_attrs|
child_transaction = Transaction.new(
category_id: split_attrs[:category_id],
merchant_id: entryable.try(:merchant_id),
kind: entryable.try(:kind)
)
child_entries.create!(
account: account,
date: date,
name: split_attrs[:name],
amount: split_attrs[:amount],
currency: currency,
excluded: TRUTHY_VALUES.include?(split_attrs[:excluded]),
entryable: child_transaction
)
end
update!(excluded: true)
mark_user_modified!
children
end
end
# Removes split children and restores parent entry.
def unsplit!
self.class.transaction do
child_entries.each do |child|
child.unsplitting = true
child.destroy!
end
update!(excluded: false)
end
end
class << self
def search(params)
EntrySearch.new(params).build_query(all)
end
# arbitrary cutoff date to avoid expensive sync operations
def min_supported_date
30.years.ago.to_date
end
# Bulk update entries with the given parameters.
#
# Tags are handled separately from other entryable attributes because they use
# a join table (taggings) rather than a direct column. This means:
# - category_id: nil means "no category" (column value)
# - tag_ids: [] means "delete all taggings" (join table operation)
#
# To avoid accidentally clearing tags when only updating other fields,
# tags are only modified when explicitly requested via update_tags: true.
#
# @param bulk_update_params [Hash] The parameters to update
# @param update_tags [Boolean] Whether to update tags (default: false)
def bulk_update!(bulk_update_params, update_tags: false)
bulk_attributes = {
date: bulk_update_params[:date],
notes: bulk_update_params[:notes],
name: bulk_update_params[:name],
entryable_attributes: {
category_id: bulk_update_params[:category_id],
merchant_id: bulk_update_params[:merchant_id]
}.compact_blank
}.compact_blank
tag_ids = Array.wrap(bulk_update_params[:tag_ids]).reject(&:blank?)
has_updates = bulk_attributes.present? || update_tags
return 0 unless has_updates
transaction do
all.each do |entry|
changed = false
# Update standard attributes
if bulk_attributes.present?
attrs = bulk_attributes.dup
attrs.delete(:date) if entry.split_child?
attrs.delete(:entryable_attributes) unless entry.transaction?
if attrs.present?
attrs[:entryable_attributes] = attrs[:entryable_attributes].dup if attrs[:entryable_attributes].present?
attrs[:entryable_attributes][:id] = entry.entryable_id if attrs[:entryable_attributes].present?
entry.update! attrs
entry.transaction.record_category_usage! if entry.transaction?
changed = true
end
end
# Handle tags separately - only when explicitly requested
if update_tags && entry.transaction?
entry.transaction.tag_ids = tag_ids
entry.transaction.save!
entry.entryable.lock_attr!(:tag_ids) if entry.transaction.tags.any?
changed = true
end
if changed
entry.lock_saved_attributes!
entry.mark_user_modified!
end
end
end
all.size
end
end
private
def cannot_unexclude_split_parent
return unless excluded_changed?(from: true, to: false) && split_parent?
errors.add(:excluded, "cannot be toggled off for a split transaction")
end
def split_child_date_matches_parent
return unless split_child? && date_changed?
return unless parent_entry.present?
return if date == parent_entry.date
errors.add(:date, "must match the parent transaction date for split children")
end
def prevent_individual_child_deletion
return if destroyed_by_association || unsplitting
throw :abort
end
end