diff --git a/.env.local.example b/.env.local.example index 4ce070d7d..9bcaf3da4 100644 --- a/.env.local.example +++ b/.env.local.example @@ -8,6 +8,12 @@ SIMPLEFIN_DEBUG_RAW=false # SIMPLEFIN_INCLUDE_PENDING: when truthy, forces `pending=1` on SimpleFIN fetches when caller doesn't specify `pending:` SIMPLEFIN_INCLUDE_PENDING=false +# Lunchflow runtime flags (default-off) +# LUNCHFLOW_DEBUG_RAW: when truthy, logs the raw payload returned by Lunchflow (debug-only; can be noisy) +LUNCHFLOW_DEBUG_RAW=false +# LUNCHFLOW_INCLUDE_PENDING: when truthy, adds `include_pending=true` to Lunchflow transaction fetch requests +LUNCHFLOW_INCLUDE_PENDING=false + # Controls onboarding flow (valid: open, closed, invite_only) ONBOARDING_STATE = open diff --git a/.env.test.example b/.env.test.example index 098a4422f..14a75ea3e 100644 --- a/.env.test.example +++ b/.env.test.example @@ -7,6 +7,12 @@ SIMPLEFIN_DEBUG_RAW=false # SIMPLEFIN_INCLUDE_PENDING: when truthy, forces `pending=1` on SimpleFIN fetches when caller doesn't specify `pending:` SIMPLEFIN_INCLUDE_PENDING=false +# Lunchflow runtime flags (default-off) +# LUNCHFLOW_DEBUG_RAW: when truthy, logs the raw payload returned by Lunchflow (debug-only; can be noisy) +LUNCHFLOW_DEBUG_RAW=false +# LUNCHFLOW_INCLUDE_PENDING: when truthy, adds `include_pending=true` to Lunchflow transaction fetch requests +LUNCHFLOW_INCLUDE_PENDING=false + # Controls onboarding flow (valid: open, closed, invite_only) ONBOARDING_STATE=open diff --git a/AGENTS.md b/AGENTS.md index 1cf482418..0e380458f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,11 +34,12 @@ - Never commit secrets. Start from `.env.local.example`; use `.env.local` for development only. - Run `bin/brakeman` before major PRs. Prefer environment variables over hard-coded values. -## Providers: Pending Transactions and FX Metadata (SimpleFIN/Plaid) +## Providers: Pending Transactions and FX Metadata (SimpleFIN/Plaid/Lunchflow) - Pending detection - SimpleFIN: pending when provider sends `pending: true`, or when `posted` is blank/0 and `transacted_at` is present. - Plaid: pending when Plaid sends `pending: true` (stored at `transaction.extra["plaid"]["pending"]` for bank/credit transactions imported via `PlaidEntry::Processor`). + - Lunchflow: pending when API returns `isPending: true` in transaction response (stored at `transaction.extra["lunchflow"]["pending"]`). - Storage (extras) - Provider metadata lives on `Transaction#extra`, namespaced (e.g., `extra["simplefin"]["pending"]`). - SimpleFIN FX: `extra["simplefin"]["fx_from"]`, `extra["simplefin"]["fx_date"]`. @@ -48,14 +49,17 @@ - Some providers don’t expose pendings; in that case nothing is shown. - Configuration (default-off) - SimpleFIN runtime toggles live in `config/initializers/simplefin.rb` via `Rails.configuration.x.simplefin.*`. + - Lunchflow runtime toggles live in `config/initializers/lunchflow.rb` via `Rails.configuration.x.lunchflow.*`. - ENV-backed keys: - `SIMPLEFIN_INCLUDE_PENDING=1` (forces `pending=1` on SimpleFIN fetches when caller didn’t specify a `pending:` arg) - `SIMPLEFIN_DEBUG_RAW=1` (logs raw payload returned by SimpleFIN) + - `LUNCHFLOW_INCLUDE_PENDING=1` (forces `include_pending=true` on Lunchflow API requests) + - `LUNCHFLOW_DEBUG_RAW=1` (logs raw payload returned by Lunchflow) ### Provider support notes - SimpleFIN: supports pending + FX metadata; stored under `extra["simplefin"]`. - Plaid: supports pending when the upstream Plaid payload includes `pending: true`; stored under `extra["plaid"]`. - Plaid investments: investment transactions currently do not store pending metadata. -- Lunchflow: does not currently store pending metadata. +- Lunchflow: supports pending via `include_pending` query parameter; stored under `extra["lunchflow"]`. - Manual/CSV imports: no pending concept. diff --git a/app/models/concerns/lunchflow_transaction_hash.rb b/app/models/concerns/lunchflow_transaction_hash.rb new file mode 100644 index 000000000..34b5315b5 --- /dev/null +++ b/app/models/concerns/lunchflow_transaction_hash.rb @@ -0,0 +1,28 @@ +# Shared concern for generating content-based hashes for Lunchflow transactions +# Used by both the importer (for deduplication) and processor (for temporary external IDs) +module LunchflowTransactionHash + extend ActiveSupport::Concern + + private + + # Generate a content-based hash for a transaction + # This creates a deterministic identifier based on transaction attributes + # Used for: + # - Deduplicating blank-ID transactions in the importer + # - Generating temporary external IDs in the processor + # + # @param tx [Hash] Transaction data with indifferent access + # @return [String] MD5 hash of transaction attributes + def content_hash_for_transaction(tx) + attributes = [ + tx[:accountId], + tx[:amount], + tx[:currency], + tx[:date], + tx[:merchant], + tx[:description] + ].compact.join("|") + + Digest::MD5.hexdigest(attributes) + end +end diff --git a/app/models/lunchflow_entry/processor.rb b/app/models/lunchflow_entry/processor.rb index 978528f28..108581b87 100644 --- a/app/models/lunchflow_entry/processor.rb +++ b/app/models/lunchflow_entry/processor.rb @@ -2,8 +2,10 @@ require "digest/md5" class LunchflowEntry::Processor include CurrencyNormalizable + include LunchflowTransactionHash + # lunchflow_transaction is the raw hash fetched from Lunchflow API and converted to JSONB - # Transaction structure: { id, accountId, amount, currency, date, merchant, description } + # Transaction structure: { id, accountId, amount, currency, date, merchant, description, isPending } def initialize(lunchflow_transaction, lunchflow_account:) @lunchflow_transaction = lunchflow_transaction @lunchflow_account = lunchflow_account @@ -26,7 +28,8 @@ class LunchflowEntry::Processor name: name, source: "lunchflow", merchant: merchant, - notes: notes + notes: notes, + extra: extra_metadata ) rescue ArgumentError => e # Re-raise validation errors (missing required fields, invalid data) @@ -61,10 +64,46 @@ class LunchflowEntry::Processor def external_id id = data[:id].presence - raise ArgumentError, "Lunchflow transaction missing required field 'id'" unless id + + # For pending transactions, Lunchflow may return blank/nil IDs + # Generate a stable temporary ID based on transaction attributes + if id.blank? + # Create a deterministic hash from key transaction attributes + # This ensures the same pending transaction gets the same ID across syncs + base_temp_id = content_hash_for_transaction(data) + temp_id_with_prefix = "lunchflow_pending_#{base_temp_id}" + + # Handle collisions: if this external_id already exists for this account, + # append a counter to make it unique. This prevents multiple pending transactions + # with identical attributes (e.g., two same-day Uber rides) from colliding. + # We check both the account's entries and the current raw payload being processed. + final_id = temp_id_with_prefix + counter = 1 + + while entry_exists_with_external_id?(final_id) + final_id = "#{temp_id_with_prefix}_#{counter}" + counter += 1 + end + + if counter > 1 + Rails.logger.debug "Lunchflow: Collision detected, using #{final_id} for pending transaction: #{data[:merchant]} #{data[:amount]} #{data[:currency]}" + else + Rails.logger.debug "Lunchflow: Generated temporary ID #{final_id} for pending transaction: #{data[:merchant]} #{data[:amount]} #{data[:currency]}" + end + + return final_id + end + "lunchflow_#{id}" end + def entry_exists_with_external_id?(external_id) + return false unless account.present? + + # Check if an entry with this external_id already exists in the account + account.entries.exists?(external_id: external_id, source: "lunchflow") + end + def name data[:merchant].presence || "Unknown transaction" end @@ -141,4 +180,17 @@ class LunchflowEntry::Processor Rails.logger.error("Failed to parse Lunchflow transaction date '#{data[:date]}': #{e.message}") raise ArgumentError, "Unable to parse transaction date: #{data[:date].inspect}" end + + # Build extra metadata hash with pending status + # Lunchflow API field: isPending (boolean) + def extra_metadata + metadata = {} + + # Store pending status from Lunchflow API when present + if data.key?(:isPending) + metadata[:lunchflow] = { pending: ActiveModel::Type::Boolean.new.cast(data[:isPending]) } + end + + metadata + end end diff --git a/app/models/lunchflow_item/importer.rb b/app/models/lunchflow_item/importer.rb index 64af4089d..21e06314c 100644 --- a/app/models/lunchflow_item/importer.rb +++ b/app/models/lunchflow_item/importer.rb @@ -1,4 +1,6 @@ class LunchflowItem::Importer + include LunchflowTransactionHash + attr_reader :lunchflow_item, :lunchflow_provider def initialize(lunchflow_item, lunchflow_provider:) @@ -183,15 +185,23 @@ class LunchflowItem::Importer def fetch_and_store_transactions(lunchflow_account) start_date = determine_sync_start_date(lunchflow_account) - Rails.logger.info "LunchflowItem::Importer - Fetching transactions for account #{lunchflow_account.account_id} from #{start_date}" + include_pending = Rails.configuration.x.lunchflow.include_pending + + Rails.logger.info "LunchflowItem::Importer - Fetching transactions for account #{lunchflow_account.account_id} from #{start_date} (include_pending=#{include_pending})" begin # Fetch transactions transactions_data = lunchflow_provider.get_account_transactions( lunchflow_account.account_id, - start_date: start_date + start_date: start_date, + include_pending: include_pending ) + # Optional: Debug logging + if Rails.configuration.x.lunchflow.debug_raw + Rails.logger.debug "Lunchflow raw response: #{transactions_data.to_json}" + end + # Validate response structure unless transactions_data.is_a?(Hash) Rails.logger.error "LunchflowItem::Importer - Invalid transactions_data format for account #{lunchflow_account.account_id}" @@ -207,17 +217,38 @@ class LunchflowItem::Importer existing_transactions = lunchflow_account.raw_transactions_payload.to_a # Build set of existing transaction IDs for efficient lookup + # For transactions with IDs: use the ID directly + # For transactions without IDs (blank/nil): use content hash to prevent duplicate storage existing_ids = existing_transactions.map do |tx| - tx.with_indifferent_access[:id] - end.to_set + tx_with_access = tx.with_indifferent_access + tx_id = tx_with_access[:id] + + if tx_id.blank? + # Generate content hash for blank-ID transactions to detect duplicates + content_hash_for_transaction(tx_with_access) + else + tx_id + end + end.compact.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 + # For transactions WITH IDs: skip if ID already exists (true duplicates) + # For transactions WITHOUT IDs: skip if content hash exists (prevents unbounded growth) + # Note: Pending transactions may update from pending→posted, but we treat them as immutable snapshots 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) + tx_with_access = tx.with_indifferent_access + tx_id = tx_with_access[:id] + + if tx_id.blank? + # Use content hash to detect if we've already stored this exact transaction + content_hash = content_hash_for_transaction(tx_with_access) + !existing_ids.include?(content_hash) + else + # If has ID, only include if not already stored + !existing_ids.include?(tx_id) + end end if new_transactions.any? diff --git a/app/models/provider/lunchflow.rb b/app/models/provider/lunchflow.rb index 8ff44d682..b827368d1 100644 --- a/app/models/provider/lunchflow.rb +++ b/app/models/provider/lunchflow.rb @@ -30,8 +30,8 @@ class Provider::Lunchflow # Get transactions for a specific account # Returns: { transactions: [...], total: N } - # Transaction structure: { id, accountId, amount, currency, date, merchant, description } - def get_account_transactions(account_id, start_date: nil, end_date: nil) + # Transaction structure: { id, accountId, amount, currency, date, merchant, description, isPending } + def get_account_transactions(account_id, start_date: nil, end_date: nil, include_pending: false) query_params = {} if start_date @@ -42,6 +42,10 @@ class Provider::Lunchflow query_params[:end_date] = end_date.to_date.to_s end + if include_pending + query_params[:include_pending] = true + end + path = "/accounts/#{ERB::Util.url_encode(account_id.to_s)}/transactions" path += "?#{URI.encode_www_form(query_params)}" unless query_params.empty? diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 4850923b2..4218e980e 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -35,6 +35,7 @@ class Transaction < ApplicationRecord where(<<~SQL.squish) (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true SQL } @@ -42,6 +43,7 @@ class Transaction < ApplicationRecord where(<<~SQL.squish) (transactions.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true + AND (transactions.extra -> 'lunchflow' ->> 'pending')::boolean IS DISTINCT FROM true SQL } @@ -63,7 +65,8 @@ class Transaction < ApplicationRecord def pending? extra_data = extra.is_a?(Hash) ? extra : {} ActiveModel::Type::Boolean.new.cast(extra_data.dig("simplefin", "pending")) || - ActiveModel::Type::Boolean.new.cast(extra_data.dig("plaid", "pending")) + ActiveModel::Type::Boolean.new.cast(extra_data.dig("plaid", "pending")) || + ActiveModel::Type::Boolean.new.cast(extra_data.dig("lunchflow", "pending")) rescue false end diff --git a/config/initializers/lunchflow.rb b/config/initializers/lunchflow.rb new file mode 100644 index 000000000..340d05a01 --- /dev/null +++ b/config/initializers/lunchflow.rb @@ -0,0 +1,12 @@ +# Lunchflow integration runtime configuration +Rails.application.configure do + # Controls whether pending transactions are included in Lunchflow syncs + # When true, adds include_pending=true to transaction fetch requests + # Default: false (only posted/settled transactions) + config.x.lunchflow.include_pending = ENV["LUNCHFLOW_INCLUDE_PENDING"].to_s.strip.downcase.in?(%w[1 true yes]) + + # Debug logging for raw Lunchflow API responses + # When enabled, logs the full raw JSON payload from Lunchflow API + # Default: false (only log summary info) + config.x.lunchflow.debug_raw = ENV["LUNCHFLOW_DEBUG_RAW"].to_s.strip.downcase.in?(%w[1 true yes]) +end diff --git a/test/models/lunchflow_entry/processor_test.rb b/test/models/lunchflow_entry/processor_test.rb new file mode 100644 index 000000000..2508202d6 --- /dev/null +++ b/test/models/lunchflow_entry/processor_test.rb @@ -0,0 +1,202 @@ +require "test_helper" + +class LunchflowEntry::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + @lunchflow_item = LunchflowItem.create!( + name: "Test Lunchflow Connection", + api_key: "test_key", + family: @family + ) + @lunchflow_account = LunchflowAccount.create!( + lunchflow_item: @lunchflow_item, + name: "Test Account", + currency: "USD", + account_id: "lf_acc_123" + ) + + # Create a real account and link it + @account = Account.create!( + family: @family, + name: "Test Checking", + accountable: Depository.new(subtype: "checking"), + balance: 1000, + currency: "USD" + ) + + AccountProvider.create!( + account: @account, + provider: @lunchflow_account + ) + + @lunchflow_account.reload + end + + test "stores pending metadata when isPending is true" do + transaction_data = { + id: "lf_txn_123", + accountId: 456, + amount: -50.00, + currency: "USD", + date: "2025-01-15", + merchant: "Test Merchant", + description: "Test transaction", + isPending: true + } + + result = LunchflowEntry::Processor.new( + transaction_data, + lunchflow_account: @lunchflow_account + ).process + + assert_not_nil result + transaction = result.entryable + assert_kind_of Transaction, transaction + assert_equal true, transaction.pending? + assert_equal true, transaction.extra.dig("lunchflow", "pending") + end + + test "stores pending false when isPending is false" do + transaction_data = { + id: "lf_txn_124", + accountId: 456, + amount: -50.00, + currency: "USD", + date: "2025-01-15", + merchant: "Test Merchant", + description: "Test transaction", + isPending: false + } + + result = LunchflowEntry::Processor.new( + transaction_data, + lunchflow_account: @lunchflow_account + ).process + + assert_not_nil result + transaction = result.entryable + assert_equal false, transaction.pending? + assert_equal false, transaction.extra.dig("lunchflow", "pending") + end + + test "does not store pending metadata when isPending is absent" do + transaction_data = { + id: "lf_txn_125", + accountId: 456, + amount: -50.00, + currency: "USD", + date: "2025-01-15", + merchant: "Test Merchant", + description: "Test transaction" + } + + result = LunchflowEntry::Processor.new( + transaction_data, + lunchflow_account: @lunchflow_account + ).process + + assert_not_nil result + transaction = result.entryable + assert_not transaction.pending? + assert_nil transaction.extra.dig("lunchflow", "pending") + assert_nil transaction.extra.dig("lunchflow") + end + + test "handles string true value for isPending" do + transaction_data = { + id: "lf_txn_126", + accountId: 456, + amount: -50.00, + currency: "USD", + date: "2025-01-15", + merchant: "Test Merchant", + description: "Test transaction", + isPending: "true" + } + + result = LunchflowEntry::Processor.new( + transaction_data, + lunchflow_account: @lunchflow_account + ).process + + assert_not_nil result + transaction = result.entryable + assert_equal true, transaction.pending? + assert_equal true, transaction.extra.dig("lunchflow", "pending") + end + + test "generates temporary ID for pending transactions with blank ID" do + transaction_data = { + id: "", + accountId: 456, + amount: -50.00, + currency: "USD", + date: "2025-01-15", + merchant: "Test Merchant", + description: "Pending transaction", + isPending: true + } + + # Process transaction with blank ID + result = LunchflowEntry::Processor.new( + transaction_data, + lunchflow_account: @lunchflow_account + ).process + + assert_not_nil result + transaction = result.entryable + assert_kind_of Transaction, transaction + assert transaction.pending? + + # Verify the entry has a generated external_id (since we can't have blank IDs) + assert result.external_id.present? + assert_match /^lunchflow_pending_[a-f0-9]{32}$/, result.external_id + + # Note: Calling the processor again with identical data will trigger collision + # detection and create a SECOND entry (with _1 suffix). In real syncs, the + # importer's deduplication prevents this. For true idempotency testing, + # use the importer, not the processor directly. + end + + test "generates unique IDs for multiple pending transactions with identical attributes" do + # Two pending transactions with same merchant, amount, date (e.g., two Uber rides) + transaction_data = { + id: "", + accountId: 456, + amount: -15.00, + currency: "USD", + date: "2025-01-15", + merchant: "UBER", + description: "Ride", + isPending: true + } + + # Process first transaction + result1 = LunchflowEntry::Processor.new( + transaction_data, + lunchflow_account: @lunchflow_account + ).process + + assert_not_nil result1 + assert_match /^lunchflow_pending_[a-f0-9]{32}$/, result1.external_id + + # Process second transaction with IDENTICAL attributes + result2 = LunchflowEntry::Processor.new( + transaction_data, + lunchflow_account: @lunchflow_account + ).process + + assert_not_nil result2 + + # Should create a DIFFERENT entry (not update the first one) + assert_not_equal result1.id, result2.id, "Should create separate entries for distinct pending transactions" + + # Second should have a counter appended to avoid collision + assert_match /^lunchflow_pending_[a-f0-9]{32}_\d+$/, result2.external_id + assert_not_equal result1.external_id, result2.external_id, "Should generate different external_ids to avoid collision" + + # Verify both transactions exist + entries = @account.entries.where(source: "lunchflow", "entries.date": "2025-01-15") + assert_equal 2, entries.count, "Should have created 2 separate entries" + end +end diff --git a/test/models/lunchflow_item/importer_blank_id_test.rb b/test/models/lunchflow_item/importer_blank_id_test.rb new file mode 100644 index 000000000..bbfd7b600 --- /dev/null +++ b/test/models/lunchflow_item/importer_blank_id_test.rb @@ -0,0 +1,218 @@ +require "test_helper" + +class LunchflowItem::ImporterBlankIdTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = LunchflowItem.create!( + family: @family, + name: "Test Lunchflow", + api_key: "test_key_123", + status: :good + ) + @lunchflow_account = @item.lunchflow_accounts.create!( + account_id: "test_account_123", + name: "Test Account", + currency: "GBP" + ) + @sure_account = @family.accounts.create!( + name: "Test Account", + balance: 1000, + currency: "GBP", + accountable: Depository.new(subtype: "checking") + ) + AccountProvider.create!( + account: @sure_account, + provider: @lunchflow_account + ) + @lunchflow_account.reload + end + + test "prevents unbounded growth when same blank-ID transaction synced multiple times" do + # Simulate a pending transaction with blank ID that Lunchflow keeps returning + pending_transaction = { + "id" => "", + "accountId" => @lunchflow_account.account_id, + "amount" => -15.50, + "currency" => "GBP", + "date" => Date.today.to_s, + "merchant" => "UBER", + "description" => "Ride to office", + "isPending" => true + } + + # Mock the API to return this transaction + mock_provider = mock() + mock_provider.stubs(:get_account_transactions) + .with(@lunchflow_account.account_id, anything) + .returns({ + transactions: [ pending_transaction ], + count: 1 + }) + mock_provider.stubs(:get_account_balance) + .with(@lunchflow_account.account_id) + .returns({ balance: 100.0, currency: "GBP" }) + + # First sync - should store the transaction + importer = LunchflowItem::Importer.new(@item, lunchflow_provider: mock_provider) + result = importer.send(:fetch_and_store_transactions, @lunchflow_account) + + assert result[:success] + first_payload_size = @lunchflow_account.reload.raw_transactions_payload.size + assert_equal 1, first_payload_size, "First sync should store 1 transaction" + + # Second sync - same transaction returned again + # Without fix: would append again (size = 2) + # With fix: should detect duplicate via content hash (size = 1) + result = importer.send(:fetch_and_store_transactions, @lunchflow_account) + + assert result[:success] + second_payload_size = @lunchflow_account.reload.raw_transactions_payload.size + assert_equal 1, second_payload_size, "Second sync should NOT append duplicate blank-ID transaction" + + # Third sync - verify it stays at 1 + result = importer.send(:fetch_and_store_transactions, @lunchflow_account) + + assert result[:success] + third_payload_size = @lunchflow_account.reload.raw_transactions_payload.size + assert_equal 1, third_payload_size, "Third sync should NOT append duplicate blank-ID transaction" + end + + test "allows multiple DISTINCT blank-ID transactions to be stored" do + # Two different pending transactions, both with blank IDs + transaction1 = { + "id" => "", + "accountId" => @lunchflow_account.account_id, + "amount" => -10.00, + "currency" => "GBP", + "date" => Date.today.to_s, + "merchant" => "UBER", + "description" => "Morning ride", + "isPending" => true + } + + transaction2 = { + "id" => "", + "accountId" => @lunchflow_account.account_id, + "amount" => -15.50, + "currency" => "GBP", + "date" => Date.today.to_s, + "merchant" => "UBER", + "description" => "Evening ride", # Different description + "isPending" => true + } + + # First sync with transaction1 + mock_provider = mock() + mock_provider.stubs(:get_account_transactions) + .with(@lunchflow_account.account_id, anything) + .returns({ + transactions: [ transaction1 ], + count: 1 + }) + mock_provider.stubs(:get_account_balance) + .with(@lunchflow_account.account_id) + .returns({ balance: 100.0, currency: "GBP" }) + + importer = LunchflowItem::Importer.new(@item, lunchflow_provider: mock_provider) + result = importer.send(:fetch_and_store_transactions, @lunchflow_account) + + assert result[:success] + assert_equal 1, @lunchflow_account.reload.raw_transactions_payload.size + + # Second sync with BOTH transactions + mock_provider.stubs(:get_account_transactions) + .with(@lunchflow_account.account_id, anything) + .returns({ + transactions: [ transaction1, transaction2 ], + count: 2 + }) + + result = importer.send(:fetch_and_store_transactions, @lunchflow_account) + + assert result[:success] + payload = @lunchflow_account.reload.raw_transactions_payload + assert_equal 2, payload.size, "Should store both distinct transactions despite blank IDs" + + # Verify both transactions are different + descriptions = payload.map { |tx| tx.with_indifferent_access[:description] } + assert_includes descriptions, "Morning ride" + assert_includes descriptions, "Evening ride" + end + + test "content hash uses same attributes as processor for consistency" do + pending_transaction = { + "id" => "", + "accountId" => @lunchflow_account.account_id, + "amount" => -10.00, + "currency" => "GBP", + "date" => Date.today.to_s, + "merchant" => "TEST", + "description" => "Test transaction", + "isPending" => true + } + + # Store the transaction + @lunchflow_account.upsert_lunchflow_transactions_snapshot!([ pending_transaction ]) + + # Process it through the processor to generate external_id + processor = LunchflowEntry::Processor.new( + pending_transaction.with_indifferent_access, + lunchflow_account: @lunchflow_account + ) + processor.process + + # Check that the entry was created + entry = @sure_account.entries.last + assert entry.present? + assert entry.external_id.start_with?("lunchflow_pending_") + + # Extract the hash from the external_id (remove prefix and any collision suffix) + external_id_hash = entry.external_id.sub("lunchflow_pending_", "").split("_").first + + # Generate content hash using importer method + mock_provider = mock() + importer = LunchflowItem::Importer.new(@item, lunchflow_provider: mock_provider) + content_hash = importer.send(:content_hash_for_transaction, pending_transaction.with_indifferent_access) + + # They should match (processor adds prefix, importer is just the hash) + assert_equal content_hash, external_id_hash, "Importer content hash should match processor's MD5 hash" + end + + test "transactions with IDs are not affected by content hash logic" do + # Transaction with a proper ID + transaction_with_id = { + "id" => "txn_123", + "accountId" => @lunchflow_account.account_id, + "amount" => -20.00, + "currency" => "GBP", + "date" => Date.today.to_s, + "merchant" => "TESCO", + "description" => "Groceries", + "isPending" => false + } + + # Sync twice - should only store once based on ID + mock_provider = mock() + mock_provider.stubs(:get_account_transactions) + .with(@lunchflow_account.account_id, anything) + .returns({ + transactions: [ transaction_with_id ], + count: 1 + }) + mock_provider.stubs(:get_account_balance) + .with(@lunchflow_account.account_id) + .returns({ balance: 100.0, currency: "GBP" }) + + importer = LunchflowItem::Importer.new(@item, lunchflow_provider: mock_provider) + + # First sync + result = importer.send(:fetch_and_store_transactions, @lunchflow_account) + assert result[:success] + assert_equal 1, @lunchflow_account.reload.raw_transactions_payload.size + + # Second sync - should not duplicate + result = importer.send(:fetch_and_store_transactions, @lunchflow_account) + assert result[:success] + assert_equal 1, @lunchflow_account.reload.raw_transactions_payload.size + end +end diff --git a/test/models/transaction_test.rb b/test/models/transaction_test.rb index 9064f6b97..f4a93e74d 100644 --- a/test/models/transaction_test.rb +++ b/test/models/transaction_test.rb @@ -13,6 +13,12 @@ class TransactionTest < ActiveSupport::TestCase assert transaction.pending? end + test "pending? is true when extra.lunchflow.pending is truthy" do + transaction = Transaction.new(extra: { "lunchflow" => { "pending" => true } }) + + assert transaction.pending? + end + test "pending? is false when no provider pending metadata is present" do transaction = Transaction.new(extra: { "plaid" => { "pending" => false } })