Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/models/account/provider_import_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,7 @@ def find_pending_transaction(date:, amount:, currency:, source:, date_window: 8)
OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true
OR (transactions.extra -> 'akahu' ->> 'pending')::boolean = true
OR (transactions.extra -> 'up' ->> 'pending')::boolean = true
OR (transactions.extra -> 'mercury' ->> 'pending')::boolean = true
SQL
.order(date: :desc) # Prefer most recent pending transaction

Expand Down Expand Up @@ -820,6 +821,7 @@ def find_pending_transaction_fuzzy(date:, amount:, currency:, source:, merchant_
OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true
OR (transactions.extra -> 'akahu' ->> 'pending')::boolean = true
OR (transactions.extra -> 'up' ->> 'pending')::boolean = true
OR (transactions.extra -> 'mercury' ->> 'pending')::boolean = true
SQL

# If merchant_id is provided, prioritize matching by merchant
Expand Down Expand Up @@ -892,6 +894,7 @@ def find_pending_transaction_low_confidence(date:, amount:, currency:, source:,
OR (transactions.extra -> 'enable_banking' ->> 'pending')::boolean = true
OR (transactions.extra -> 'akahu' ->> 'pending')::boolean = true
OR (transactions.extra -> 'up' ->> 'pending')::boolean = true
OR (transactions.extra -> 'mercury' ->> 'pending')::boolean = true
SQL

# For low confidence, require BOTH merchant AND name match (stronger signal needed)
Expand Down
14 changes: 13 additions & 1 deletion app/models/mercury_entry/processor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ def process
name: name,
source: "mercury",
merchant: merchant,
notes: notes
notes: notes,
extra: extra
)
rescue ArgumentError => e
# Re-raise validation errors (missing required fields, invalid data)
Expand Down Expand Up @@ -114,6 +115,17 @@ def merchant
end
end

def extra
meta = { "pending" => pending? }
Comment thread
JSONbored marked this conversation as resolved.
meta["kind"] = data[:kind] if data[:kind].present?
meta["counterparty_id"] = data[:counterpartyId] if data[:counterpartyId].present?
{ "mercury" => meta }
end

def pending?
data[:status] == "pending"
end

def amount
parsed_amount = case data[:amount]
when String
Expand Down
61 changes: 45 additions & 16 deletions app/models/mercury_item/importer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -209,25 +209,54 @@ def fetch_and_store_transactions(mercury_account)
begin
existing_transactions = mercury_account.raw_transactions_payload.to_a

# Build set of existing transaction IDs for efficient lookup
existing_ids = existing_transactions.map do |tx|
tx.with_indifferent_access[:id]
end.to_set

# Filter to ONLY truly new transactions (skip duplicates)
# Transactions are immutable on the bank side, so we don't need to update them
new_transactions = transactions_data[:transactions].select do |tx|
next false unless tx.is_a?(Hash)

tx_id = tx.with_indifferent_access[:id]
tx_id.present? && !existing_ids.include?(tx_id)
# Build a map of existing transaction IDs for efficient lookup
existing_by_id = existing_transactions.index_by { |tx| tx.with_indifferent_access[:id] }

new_transactions = []
updated_transactions = []

transactions_data[:transactions].each do |tx|
next unless tx.is_a?(Hash)

tx_data = tx.with_indifferent_access
tx_id = tx_data[:id]
next unless tx_id.present?

if existing_by_id.key?(tx_id)
existing = existing_by_id[tx_id].with_indifferent_access
# Mercury reuses the same transaction ID when a pending entry posts.
# Replace the stored copy so the processor clears the pending flag.
if existing[:status] == "pending" && tx_data[:status] != "pending"
existing_by_id[tx_id] = tx
updated_transactions << tx_id
end
else
new_transactions << tx
end
end

if new_transactions.any?
Rails.logger.info "MercuryItem::Importer - Storing #{new_transactions.count} new transactions (#{existing_transactions.count} existing, #{transactions_data[:transactions].count - new_transactions.count} duplicates skipped) for account #{mercury_account.account_id}"
mercury_account.upsert_mercury_transactions_snapshot!(existing_transactions + new_transactions)
if new_transactions.any? || updated_transactions.any?
merged = existing_by_id.values + new_transactions
DebugLogEntry.capture(
category: "provider_sync",
level: "info",
message: "Storing #{new_transactions.count} new, #{updated_transactions.count} status-updated transactions for account #{mercury_account.account_id}",
source: self.class.name,
provider_key: "mercury",
family: mercury_item.family,
metadata: { account_id: mercury_account.account_id, new_count: new_transactions.count, updated_count: updated_transactions.count }
)
mercury_account.upsert_mercury_transactions_snapshot!(merged)
else
Rails.logger.info "MercuryItem::Importer - No new transactions to store (all #{transactions_data[:transactions].count} were duplicates) for account #{mercury_account.account_id}"
DebugLogEntry.capture(
category: "provider_sync",
level: "info",
message: "No new or updated transactions for account #{mercury_account.account_id}",
source: self.class.name,
provider_key: "mercury",
family: mercury_item.family,
metadata: { account_id: mercury_account.account_id }
)
end
rescue => e
Rails.logger.error "MercuryItem::Importer - Failed to store transactions for account #{mercury_account.account_id}: #{e.message}"
Expand Down
2 changes: 1 addition & 1 deletion app/models/transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def exchange_rate_must_be_valid
INTERNAL_MOVEMENT_LABELS = [ "Transfer", "Sweep In", "Sweep Out", "Exchange" ].freeze

# Providers that support pending transaction flags
PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking akahu up].freeze
PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking akahu up mercury].freeze
Comment thread
JSONbored marked this conversation as resolved.

# Pre-computed SQL fragment for subqueries that check if a transaction (aliased as "t") is pending.
# Stored as a constant so static analysis can verify it contains no user input.
Expand Down
98 changes: 98 additions & 0 deletions test/models/mercury_account/processor_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# frozen_string_literal: true

require "test_helper"

class MercuryAccount::ProcessorTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@item = MercuryItem.create!(family: @family, name: "Mercury", token: "tok")
end

# ---------------------------------------------------------------------------
# balance update
# ---------------------------------------------------------------------------

test "updates account balance from mercury_account current_balance" do
account = create_account("Checking")
mercury_account = create_mercury_account("acc_001", balance: 12_345.67, account: account)

MercuryAccount::Processor.new(mercury_account).process

assert_in_delta 12_345.67, account.reload.balance, 0.01
end

test "negates balance for CreditCard accounts" do
account = @family.accounts.create!(
name: "Mercury Credit", balance: 0, currency: "USD",
accountable: CreditCard.new
)
mercury_account = create_mercury_account("acc_credit", balance: 500.0, account: account)

MercuryAccount::Processor.new(mercury_account).process

assert_in_delta(-500.0, account.reload.balance, 0.01)
end

test "sets cash_balance equal to balance for depository" do
account = create_account("Savings")
mercury_account = create_mercury_account("acc_002", balance: 3_000.0, account: account)

MercuryAccount::Processor.new(mercury_account).process

assert_in_delta 3_000.0, account.reload.cash_balance, 0.01
end

# ---------------------------------------------------------------------------
# no linked account
# ---------------------------------------------------------------------------

test "returns nil without error when no linked account" do
mercury_account = @item.mercury_accounts.create!(
name: "Unlinked", account_id: "acc_unlinked", currency: "USD", current_balance: 100
)

result = nil
assert_nothing_raised do
result = MercuryAccount::Processor.new(mercury_account).process
end
Comment thread
coderabbitai[bot] marked this conversation as resolved.
assert_nil result
end

# ---------------------------------------------------------------------------
# transaction processing delegation
# ---------------------------------------------------------------------------

test "processes transactions stored in raw_transactions_payload" do
account = create_account("Checking")
mercury_account = create_mercury_account("acc_003", balance: 0, account: account,
raw_transactions: [
{ "id" => "tx_a", "amount" => 100.0, "status" => "sent",
"bankDescription" => "Deposit", "createdAt" => "2024-06-01T00:00:00Z",
"postedAt" => "2024-06-01T00:00:00Z" }
]
)

assert_difference "account.entries.count", 1 do
MercuryAccount::Processor.new(mercury_account).process
end
end

private

def create_account(name)
@family.accounts.create!(
name: name, balance: 0, currency: "USD",
accountable: Depository.new(subtype: "checking")
)
end

def create_mercury_account(account_id, balance:, account:, raw_transactions: [])
ma = @item.mercury_accounts.create!(
name: account_id, account_id: account_id, currency: "USD",
current_balance: balance,
raw_transactions_payload: raw_transactions
)
AccountProvider.create!(provider: ma, account: account)
ma
end
end
Loading
Loading