mirror of
https://github.com/we-promise/sure.git
synced 2026-07-16 14:55:23 +00:00
* feat(mercury): pending transactions, kind/counterpartyId metadata, and test coverage
- Transaction::PENDING_PROVIDERS: add "mercury" so the existing pending
reconciliation pipeline (pending→posted amount matching in
ProviderImportAdapter) activates for Mercury entries
- MercuryEntry::Processor: pass extra: to import_transaction with
extra["mercury"]["pending"] = true/false (status == "pending")
extra["mercury"]["kind"] = ACH / Wire / Card / etc.
extra["mercury"]["counterparty_id"] = Mercury counterparty UUID
Pending transactions are now imported with the flag set rather than
being silently ignored; failed transactions continue to be skipped
- Tests (new files, 30 cases):
- test/models/mercury_entry/processor_test.rb — sign convention,
date fallback, name priority, notes concat, pending flag, kind,
counterpartyId, failed skip, idempotency, merchant creation,
no-linked-account guard
- test/models/mercury_item/importer_test.rb — account discovery,
no-duplicate unlinked records, balance update on linked accounts,
transaction dedup (append-only new ids), sync window (90-day first
sync, last_synced_at-7d subsequent), 401 marks requires_update
- test/models/mercury_account/processor_test.rb — balance update,
CreditCard sign negation, cash_balance parity, no-linked-account
no-op, transaction processing delegation
* fix(mercury): address review findings — pending SQL, dedup upsert, N+1, nil assertion
- provider_import_adapter: add mercury to all three find_pending_transaction*
SQL predicates so Mercury pending entries are found and claimed when the
posted version arrives (exact, fuzzy, and low-confidence paths)
- mercury_item/importer: replace append-only dedup with an upsert-by-id
that replaces the stored raw payload when status changes from pending to
non-pending; prevents pending flag from persisting indefinitely when Mercury
reuses the same transaction ID for the posted version
- kraken_account/ledger_processor: preload all existing kraken ledger
external_ids into a Set before the loop; replaces per-entry exists? query
(N+1) with an in-memory Set#include? lookup
- test/models/mercury_account/processor_test: capture return value from
process and add assert_nil to enforce the nil contract stated in the test name
* fix(mercury): route transaction-count diagnostics through DebugLogEntry
216 lines
7.6 KiB
Ruby
216 lines
7.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
|
|
class MercuryEntry::ProcessorTest < ActiveSupport::TestCase
|
|
setup do
|
|
@family = families(:dylan_family)
|
|
@account = @family.accounts.create!(
|
|
name: "Mercury Checking", balance: 0, currency: "USD",
|
|
accountable: Depository.new(subtype: "checking")
|
|
)
|
|
@item = MercuryItem.create!(
|
|
family: @family, name: "Mercury", token: "test_token"
|
|
)
|
|
@mercury_account = @item.mercury_accounts.create!(
|
|
name: "Mercury Checking", account_id: "acc_001", currency: "USD", current_balance: 0
|
|
)
|
|
AccountProvider.create!(provider: @mercury_account, account: @account)
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# happy-path posted transaction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
test "imports a posted transaction with correct sign conversion" do
|
|
assert_difference "@account.entries.count", 1 do
|
|
process(tx(amount: 150.00, status: "sent"))
|
|
end
|
|
|
|
entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury")
|
|
assert entry
|
|
# Mercury positive = inflow; Sure convention negates it → negative
|
|
assert entry.amount.negative?
|
|
assert_in_delta(-150.0, entry.amount.to_f, 0.01)
|
|
end
|
|
|
|
test "expense (negative Mercury amount) becomes positive outflow in Sure" do
|
|
assert_difference "@account.entries.count", 1 do
|
|
process(tx(amount: -75.50, status: "sent"))
|
|
end
|
|
|
|
entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury")
|
|
assert entry
|
|
assert entry.amount.positive?
|
|
assert_in_delta 75.5, entry.amount.to_f, 0.01
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# name resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
test "prefers counterpartyNickname over counterpartyName over bankDescription" do
|
|
process(tx(counterparty_nickname: "Nick", counterparty_name: "Full Name", bank_description: "Bank Desc"))
|
|
assert_equal "Nick", @account.entries.last.name
|
|
end
|
|
|
|
test "falls back to counterpartyName when nickname absent" do
|
|
process(tx(counterparty_name: "Acme Corp"))
|
|
assert_equal "Acme Corp", @account.entries.last.name
|
|
end
|
|
|
|
test "falls back to bankDescription when no counterparty" do
|
|
process(tx(bank_description: "ACH Credit"))
|
|
assert_equal "ACH Credit", @account.entries.last.name
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# date resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
test "uses postedAt when present" do
|
|
process(tx(posted_at: "2024-03-15T00:00:00Z", created_at: "2024-03-10T00:00:00Z"))
|
|
assert_equal Date.new(2024, 3, 15), @account.entries.last.date
|
|
end
|
|
|
|
test "falls back to createdAt when postedAt absent" do
|
|
process(tx(posted_at: nil, created_at: "2024-03-10T00:00:00Z"))
|
|
assert_equal Date.new(2024, 3, 10), @account.entries.last.date
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# notes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
test "concatenates note and details with separator" do
|
|
process(tx(note: "Office supplies", details: "Q1 restock"))
|
|
assert_equal "Office supplies - Q1 restock", @account.entries.last.notes
|
|
end
|
|
|
|
test "note alone stored without separator" do
|
|
process(tx(note: "Reimbursement"))
|
|
assert_equal "Reimbursement", @account.entries.last.notes
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# extra metadata: pending, kind, counterpartyId
|
|
# ---------------------------------------------------------------------------
|
|
|
|
test "marks pending transactions in extra" do
|
|
process(tx(status: "pending"))
|
|
|
|
entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury")
|
|
assert entry
|
|
assert entry.entryable.extra.dig("mercury", "pending"), "pending flag must be true"
|
|
end
|
|
|
|
test "posted transactions have pending=false in extra" do
|
|
process(tx(status: "sent"))
|
|
|
|
entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury")
|
|
assert_equal false, entry.entryable.extra.dig("mercury", "pending")
|
|
end
|
|
|
|
test "stores transaction kind in extra" do
|
|
process(tx(kind: "externalTransfer"))
|
|
|
|
entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury")
|
|
assert_equal "externalTransfer", entry.entryable.extra.dig("mercury", "kind")
|
|
end
|
|
|
|
test "stores counterpartyId in extra" do
|
|
process(tx(counterparty_id: "cpty_abc123"))
|
|
|
|
entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury")
|
|
assert_equal "cpty_abc123", entry.entryable.extra.dig("mercury", "counterparty_id")
|
|
end
|
|
|
|
test "does not store nil kind in extra" do
|
|
process(tx)
|
|
entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury")
|
|
assert_nil entry.entryable.extra.dig("mercury", "kind")
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# skipped statuses
|
|
# ---------------------------------------------------------------------------
|
|
|
|
test "skips failed transactions" do
|
|
assert_no_difference "@account.entries.count" do
|
|
result = process(tx(status: "failed"))
|
|
assert_nil result
|
|
end
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# idempotency
|
|
# ---------------------------------------------------------------------------
|
|
|
|
test "does not create duplicate entries on re-process" do
|
|
process(tx)
|
|
assert_no_difference "@account.entries.count" do
|
|
process(tx)
|
|
end
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# merchant creation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
test "creates merchant from counterpartyName" do
|
|
process(tx(counterparty_name: "Stripe Inc"))
|
|
entry = @account.entries.find_by(external_id: "mercury_tx_001", source: "mercury")
|
|
assert entry.entryable.merchant.present?
|
|
assert_equal "Stripe Inc", entry.entryable.merchant.name
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# missing linked account
|
|
# ---------------------------------------------------------------------------
|
|
|
|
test "returns nil when mercury_account has no linked account" do
|
|
AccountProvider.where(provider: @mercury_account).destroy_all
|
|
|
|
assert_no_difference "@account.entries.count" do
|
|
result = process(tx)
|
|
assert_nil result
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def process(transaction_data)
|
|
MercuryEntry::Processor.new(transaction_data, mercury_account: @mercury_account).process
|
|
end
|
|
|
|
def tx(
|
|
id: "tx_001",
|
|
amount: 100.0,
|
|
status: "sent",
|
|
counterparty_name: nil,
|
|
counterparty_nickname: nil,
|
|
counterparty_id: nil,
|
|
bank_description: "Test Transaction",
|
|
kind: nil,
|
|
note: nil,
|
|
details: nil,
|
|
posted_at: "2024-06-01T12:00:00Z",
|
|
created_at: "2024-06-01T10:00:00Z"
|
|
)
|
|
{
|
|
"id" => id,
|
|
"amount" => amount,
|
|
"status" => status,
|
|
"counterpartyName" => counterparty_name,
|
|
"counterpartyNickname" => counterparty_nickname,
|
|
"counterpartyId" => counterparty_id,
|
|
"bankDescription" => bank_description,
|
|
"kind" => kind,
|
|
"note" => note,
|
|
"details" => details,
|
|
"postedAt" => posted_at,
|
|
"createdAt" => created_at
|
|
}
|
|
end
|
|
end
|