Files
sure/test/models/pdf_import_reconciliation_test.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

404 lines
16 KiB
Ruby

require "test_helper"
# The behaviour issue #1379 actually asked for: a statement should only offer
# transactions that are not already recorded, and should mark the rest reconciled.
class PdfImportReconciliationTest < ActiveSupport::TestCase
include EntriesTestHelper
setup do
@account = accounts(:depository)
@family = @account.family
@statement = create_statement
@import = PdfImport.create_from_statement!(statement: @statement)
@import.update!(document_type: "bank_statement")
@date = Date.current
end
# Extractor convention: negative for debits, positive for credits. Import
# signage ("inflows_positive") flips that into Entry convention, where an
# expense is positive.
def extracted(date:, amount:, name:)
{ "date" => date.to_s, "amount" => amount.to_s, "name" => name }
end
test "rows are only generated for transactions that are not already recorded" do
create_transaction(account: @account, date: @date, amount: 50, name: "Coffee")
@import.update!(extracted_data: { "transactions" => [
extracted(date: @date, amount: -50, name: "COFFEE SHOP"),
extracted(date: @date, amount: -12, name: "BOOKSTORE")
] })
@import.generate_rows_from_extracted_data
assert_equal 1, @import.reload.rows_count
assert_equal "BOOKSTORE", @import.rows.sole.name
end
test "the already-recorded transaction is marked reconciled against the statement" do
existing = create_transaction(account: @account, date: @date, amount: 50, name: "Coffee")
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -50, name: "COFFEE SHOP") ] })
@import.generate_rows_from_extracted_data
existing.reload
assert_equal :reconciled, existing.reconciliation_state
assert_equal @statement, existing.reconciled_by_statement
assert_equal [ existing ], @import.reconciled_entries.to_a
end
test "a provider-synced transaction counts as already recorded" do
synced = create_transaction(
account: @account, date: @date, amount: 50, name: "COFFEE", external_id: "plaid-1", source: "plaid"
)
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -50, name: "Coffee Shop") ] })
@import.generate_rows_from_extracted_data
assert_equal 0, @import.reload.rows_count
assert_equal :reconciled, synced.reload.reconciliation_state
end
test "a statement date a day or two off the recorded date still matches" do
create_transaction(account: @account, date: @date - 2, amount: 50, name: "Coffee")
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -50, name: "Coffee") ] })
@import.generate_rows_from_extracted_data
assert_equal 0, @import.reload.rows_count
end
test "two identical statement lines do not both match one recorded transaction" do
create_transaction(account: @account, date: @date, amount: 50, name: "Coffee")
@import.update!(extracted_data: { "transactions" => [
extracted(date: @date, amount: -50, name: "Coffee"),
extracted(date: @date, amount: -50, name: "Coffee")
] })
@import.generate_rows_from_extracted_data
assert_equal 1, @import.reload.rows_count, "the second identical line is genuinely new"
end
test "with no account assigned every transaction is offered" do
@import.update!(account: nil)
create_transaction(account: @account, date: @date, amount: 50, name: "Coffee")
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -50, name: "Coffee") ] })
@import.generate_rows_from_extracted_data
assert_equal 1, @import.reload.rows_count
end
test "a row whose date cannot be parsed is offered rather than dropped" do
@import.update!(extracted_data: { "transactions" => [
{ "date" => "not-a-date", "amount" => "-50", "name" => "Mystery" }
] })
@import.generate_rows_from_extracted_data
assert_equal 1, @import.reload.rows_count
end
test "a row whose amount cannot be parsed is offered with its raw value intact" do
@import.update!(extracted_data: { "transactions" => [
{ "date" => @date.to_s, "amount" => "not-a-number", "name" => "Mystery" }
] })
@import.generate_rows_from_extracted_data
row = @import.reload.rows.sole
assert_equal 1, @import.rows_count
# Stored verbatim rather than coerced to 0, so the review step shows the user
# what the statement actually said.
assert_equal "not-a-number", row.amount
end
test "reassigning the account re-judges the rows and releases the old reconciliation" do
other = @family.accounts.create!(
name: "Second Checking", balance: 0, currency: "USD", accountable: Depository.new
)
on_first = create_transaction(account: @account, date: @date, amount: 50, name: "Coffee")
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -50, name: "Coffee") ] })
@import.generate_rows_from_extracted_data
assert_equal 0, @import.reload.rows_count
@import.assign_account!(other)
assert_not on_first.reload.reconciled?, "the old account's entry is no longer evidence-backed"
assert_equal 1, @import.reload.rows_count, "nothing on the new account matches, so it is offered"
end
test "extract_transactions stores string keys so rows can actually be generated" do
provider = mock("llm_provider")
Provider::Registry.stubs(:preferred_llm_provider).returns(provider)
provider.stubs(:extract_bank_statement).returns(
OpenStruct.new(success?: true, data: { transactions: [ { date: @date.to_s, amount: "-5.0", name: "Coffee" } ] })
)
@import.stubs(:pdf_file_content).returns("fake-pdf")
@import.extract_transactions
# Without deep_stringify_keys this reads back empty and no rows are built.
assert_equal 1, @import.extracted_transactions.size
assert_equal "Coffee", @import.extracted_transactions.first["name"]
end
test "publishing does not re-match a row against an entry row generation already consumed" do
create_transaction(account: @account, date: @date, amount: 50, name: "Coffee")
# Two identical statement lines, one existing transaction: the first line
# reconciles against it, the second is genuinely new and must be created.
@import.update!(extracted_data: { "transactions" => [
extracted(date: @date, amount: -50, name: "Coffee"),
extracted(date: @date, amount: -50, name: "Coffee")
] })
@import.generate_rows_from_extracted_data
assert_equal 1, @import.reload.rows_count
assert_difference -> { @account.entries.count }, 1 do
@import.import!
end
end
test "assigning an account that reconciles every row completes the import" do
@import.update!(account: nil, status: :pending)
create_transaction(account: @account, date: @date, amount: 50, name: "Coffee")
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -50, name: "Coffee") ] })
@import.generate_rows_from_extracted_data
assert_equal 1, @import.reload.rows_count, "nothing matches while no account is assigned"
@import.assign_account!(@account)
@import.reload
assert_equal 0, @import.rows_count
assert @import.complete?, "a fully reconciled import must finish rather than sit at pending with no rows"
end
test "assigning an account that matches nothing returns the import to pending" do
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -50, name: "Coffee") ] })
@import.generate_rows_from_extracted_data
@import.update!(status: :complete)
other = @family.accounts.create!(
name: "Untouched Checking", balance: 0, currency: "USD", accountable: Depository.new
)
# complete but nothing of its own committed: still the user's to re-target.
assert @import.assign_account!(other)
@import.reload
assert_equal 1, @import.rows_count
assert @import.pending?
end
test "a row that cannot be evaluated is recorded in the debug log" do
@import.update!(extracted_data: { "transactions" => [
{ "date" => "not-a-date", "amount" => "-50", "name" => "Mystery" }
] })
assert_difference "DebugLogEntry.count", 1 do
@import.generate_rows_from_extracted_data
end
logged = DebugLogEntry.last
assert_equal "import", logged.category
assert_equal @family, logged.family
assert_equal @import.id, logged.metadata["import_id"]
end
test "regenerating rows twice does not collide on source row numbers" do
# insert_all! leaves the rows association stale, so a second generation on
# the same in-memory record used to re-insert source_row_number 1.
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -12, name: "Bookstore") ] })
@import.generate_rows_from_extracted_data
@import.generate_rows_from_extracted_data
assert_equal 1, @import.reload.rows_count
assert_equal 1, @import.rows.count
end
test "releasing reconciliations leaves another account's evidence intact" do
other = @family.accounts.create!(
name: "Sibling Checking", balance: 0, currency: "USD", accountable: Depository.new
)
# Same statement, evidence recorded against a second account by another import.
on_other = create_transaction(account: other, date: @date, amount: 99, name: "Elsewhere")
on_other.mark_reconciled!(statement: @statement)
on_first = create_transaction(account: @account, date: @date, amount: 50, name: "Coffee")
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -50, name: "Coffee") ] })
@import.generate_rows_from_extracted_data
assert on_first.reload.reconciled?
@import.assign_account!(other)
# Both fields matter: reconciled_at is the state, reconciled_by_statement_id
# is the evidence, and Entry#unmark_reconciled! clears the pair.
on_first.reload
assert_not on_first.reconciled?, "the account being left is released"
assert_nil on_first.reconciled_by_statement_id, "its statement evidence is cleared too"
on_other.reload
assert on_other.reconciled?, "another account's evidence must survive"
assert_equal @statement.id, on_other.reconciled_by_statement_id
end
test "reverting a fully reconciled import completes it rather than stranding it at pending" do
create_transaction(account: @account, date: @date, amount: 50, name: "Coffee")
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -50, name: "Coffee") ] })
@import.generate_rows_from_extracted_data
@import.update!(status: :complete)
assert_equal 0, @import.reload.rows_count
@import.revert
@import.reload
assert_nil @import.error
# pending with no rows renders the processing screen with no way back out.
assert @import.complete?, "expected complete, got #{@import.status}"
assert_equal 0, @import.rows_count
end
test "reverting an import puts the transactions it created back on offer" do
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -50, name: "Coffee") ] })
@import.generate_rows_from_extracted_data
assert_equal 1, @import.reload.rows_count
assert_difference -> { @account.entries.count }, 1 do
@import.import!
end
@import.update!(status: :complete)
assert_difference -> { @account.entries.count }, -1 do
@import.revert
end
@import.reload
assert @import.pending?, "expected pending, got #{@import.status}"
assert_equal 1, @import.rows_count, "the created entry is gone, so its statement line is new again"
end
test "reverting releases evidence from an entry the statement no longer matches" do
drifted = create_transaction(account: @account, date: @date, amount: 50, name: "Coffee")
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -50, name: "Coffee") ] })
@import.generate_rows_from_extracted_data
assert drifted.reload.reconciled?
@import.update!(status: :complete)
# Corrected after the fact, so the statement line no longer describes it.
drifted.update!(amount: 75)
@import.revert
drifted.reload
assert_not drifted.reconciled?, "a statement that no longer matches must stop claiming the entry"
assert_nil drifted.reconciled_by_statement_id
assert_equal 1, @import.reload.rows_count
assert @import.pending?, "expected pending, got #{@import.status}"
end
test "reassigning a published import is refused rather than half-unwinding it" do
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -50, name: "Coffee") ] })
@import.generate_rows_from_extracted_data
@import.import!
@import.update!(status: :complete)
other = @family.accounts.create!(
name: "Wrong Turn Checking", balance: 0, currency: "USD", accountable: Depository.new
)
assert_not @import.assign_account!(other), "a replayed PATCH must not unwind a published import"
@import.reload
assert_equal @account, @import.account
assert_equal 1, @import.rows_count, "the rows recording what was published survive"
assert_equal 1, @import.entries.count
end
test "reassigning is refused while a job still owns the record" do
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -50, name: "Coffee") ] })
@import.generate_rows_from_extracted_data
@import.update!(status: :importing)
other = @family.accounts.create!(
name: "Mid Flight Checking", balance: 0, currency: "USD", accountable: Depository.new
)
assert_not @import.assign_account!(other)
assert_equal @account, @import.reload.account
end
test "the outcome counts separate what was already recorded from what is new" do
create_transaction(account: @account, date: @date, amount: 50, name: "Coffee")
@import.update!(extracted_data: { "transactions" => [
extracted(date: @date, amount: -50, name: "Coffee"),
extracted(date: @date, amount: -12, name: "Bookstore")
] })
@import.generate_rows_from_extracted_data
@import.reload
assert_equal 2, @import.extracted_count
assert_equal 1, @import.already_recorded_count
assert_equal 0, @import.imported_count
assert_equal 1, @import.awaiting_review_count
assert @import.reconciled_anything?
end
test "publishing moves the offered rows into the imported count" do
create_transaction(account: @account, date: @date, amount: 50, name: "Coffee")
@import.update!(extracted_data: { "transactions" => [
extracted(date: @date, amount: -50, name: "Coffee"),
extracted(date: @date, amount: -12, name: "Bookstore")
] })
@import.generate_rows_from_extracted_data
@import.import!
@import.reload
# Entries this import created are born reconciled too, so they must not be
# counted as transactions the account already had.
assert_equal 1, @import.already_recorded_count
assert_equal 1, @import.imported_count
# Rows survive publishing as the record of what was written, so the count
# has to stop describing them as a queue.
assert_equal 0, @import.awaiting_review_count
end
test "a fully reconciled statement reports everything as already recorded" do
create_transaction(account: @account, date: @date, amount: 50, name: "Coffee")
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -50, name: "Coffee") ] })
@import.generate_rows_from_extracted_data
@import.reload
assert_equal 1, @import.extracted_count
assert_equal 1, @import.already_recorded_count
assert_equal 0, @import.imported_count
assert_equal 0, @import.awaiting_review_count
end
test "an import that matched nothing reports nothing as already recorded" do
@import.update!(extracted_data: { "transactions" => [ extracted(date: @date, amount: -12, name: "Bookstore") ] })
@import.generate_rows_from_extracted_data
@import.reload
assert_equal 0, @import.already_recorded_count
assert_not @import.reconciled_anything?
end
private
def create_statement
AccountStatement.create_from_upload!(
family: @family,
account: @account,
file: uploaded_file(
filename: "statement.pdf",
content_type: "application/pdf",
content: file_fixture("imports/sample_bank_statement.pdf").binread
)
)
end
end