Lunch flow improvements (#268)

- Add support to link existing account with lunch-flow
The account will be promoted to a lunch flow connection now
( TBD if we want to allow un-linking? )
- Add support for proper de-dup at provider import level. This will handle de-dups for Lunch Flow, Plaid and SimpleFIN
- Fix plaid account removal on invalid credentials
This commit is contained in:
soky srm
2025-10-31 13:29:44 +01:00
committed by GitHub
parent da114b5b3d
commit 106fcd06e4
11 changed files with 561 additions and 5 deletions

View File

@@ -566,4 +566,223 @@ class Account::ProviderImportAdapterTest < ActiveSupport::TestCase
assert_match(/Entry with external_id.*already exists with different entryable type/i, exception.message)
end
test "claims manual transaction when provider syncs matching transaction" do
# Create a manual transaction (no external_id or source)
manual_entry = @account.entries.create!(
date: Date.today,
amount: 42.50,
currency: "USD",
name: "Coffee Shop",
entryable: Transaction.new
)
assert_nil manual_entry.external_id
assert_nil manual_entry.source
# Provider syncs a matching transaction - should claim the manual entry, not create new
assert_no_difference "@account.entries.count" do
entry = @adapter.import_transaction(
external_id: "lunchflow_12345",
amount: 42.50,
currency: "USD",
date: Date.today,
name: "Coffee Shop - Lunchflow",
source: "lunchflow"
)
# Should be the same entry, now claimed by the provider
assert_equal manual_entry.id, entry.id
assert_equal "lunchflow_12345", entry.external_id
assert_equal "lunchflow", entry.source
assert_equal "Coffee Shop - Lunchflow", entry.name
end
end
test "claims CSV imported transaction when provider syncs matching transaction" do
# Create a CSV imported transaction (has import_id but no external_id)
import = Import.create!(
family: @family,
type: "TransactionImport",
status: :complete
)
csv_entry = @account.entries.create!(
date: Date.today - 1.day,
amount: 125.00,
currency: "USD",
name: "Grocery Store",
import: import,
entryable: Transaction.new
)
assert_nil csv_entry.external_id
assert_nil csv_entry.source
assert_equal import.id, csv_entry.import_id
# Provider syncs a matching transaction - should claim the CSV entry
assert_no_difference "@account.entries.count" do
entry = @adapter.import_transaction(
external_id: "plaid_csv_match",
amount: 125.00,
currency: "USD",
date: Date.today - 1.day,
name: "Grocery Store - Plaid",
source: "plaid"
)
# Should be the same entry, now claimed by the provider
assert_equal csv_entry.id, entry.id
assert_equal "plaid_csv_match", entry.external_id
assert_equal "plaid", entry.source
assert_equal import.id, entry.import_id # Should preserve the import_id
end
end
test "does not claim transaction when date does not match" do
# Create a manual transaction
manual_entry = @account.entries.create!(
date: Date.today - 5.days,
amount: 50.00,
currency: "USD",
name: "Restaurant",
entryable: Transaction.new
)
# Provider syncs similar transaction but different date - should create new entry
assert_difference "@account.entries.count", 1 do
entry = @adapter.import_transaction(
external_id: "lunchflow_different_date",
amount: 50.00,
currency: "USD",
date: Date.today,
name: "Restaurant",
source: "lunchflow"
)
# Should be a different entry
assert_not_equal manual_entry.id, entry.id
end
end
test "does not claim transaction when amount does not match" do
# Create a manual transaction
manual_entry = @account.entries.create!(
date: Date.today,
amount: 50.00,
currency: "USD",
name: "Restaurant",
entryable: Transaction.new
)
# Provider syncs similar transaction but different amount - should create new entry
assert_difference "@account.entries.count", 1 do
entry = @adapter.import_transaction(
external_id: "lunchflow_different_amount",
amount: 51.00,
currency: "USD",
date: Date.today,
name: "Restaurant",
source: "lunchflow"
)
# Should be a different entry
assert_not_equal manual_entry.id, entry.id
end
end
test "does not claim transaction when currency does not match" do
# Create a manual transaction
manual_entry = @account.entries.create!(
date: Date.today,
amount: 50.00,
currency: "EUR",
name: "Restaurant",
entryable: Transaction.new
)
# Provider syncs similar transaction but different currency - should create new entry
assert_difference "@account.entries.count", 1 do
entry = @adapter.import_transaction(
external_id: "lunchflow_different_currency",
amount: 50.00,
currency: "USD",
date: Date.today,
name: "Restaurant",
source: "lunchflow"
)
# Should be a different entry
assert_not_equal manual_entry.id, entry.id
end
end
test "does not claim transaction that already has external_id from different provider" do
# Create a transaction already synced from SimpleFin
simplefin_entry = @adapter.import_transaction(
external_id: "simplefin_123",
amount: 30.00,
currency: "USD",
date: Date.today,
name: "Gas Station",
source: "simplefin"
)
# Provider (Lunchflow) syncs matching transaction - should create new entry, not claim SimpleFin's
assert_difference "@account.entries.count", 1 do
entry = @adapter.import_transaction(
external_id: "lunchflow_gas",
amount: 30.00,
currency: "USD",
date: Date.today,
name: "Gas Station",
source: "lunchflow"
)
# Should be a different entry because SimpleFin already claimed it
assert_not_equal simplefin_entry.id, entry.id
assert_equal "lunchflow", entry.source
assert_equal "simplefin", simplefin_entry.reload.source
end
end
test "claims oldest matching manual transaction when multiple exist" do
# Create multiple manual transactions with same date, amount, currency
older_entry = @account.entries.create!(
date: Date.today,
amount: 20.00,
currency: "USD",
name: "Parking - Old",
entryable: Transaction.new,
created_at: 2.hours.ago
)
newer_entry = @account.entries.create!(
date: Date.today,
amount: 20.00,
currency: "USD",
name: "Parking - New",
entryable: Transaction.new,
created_at: 1.hour.ago
)
# Provider syncs matching transaction - should claim the oldest one
assert_no_difference "@account.entries.count" do
entry = @adapter.import_transaction(
external_id: "lunchflow_parking",
amount: 20.00,
currency: "USD",
date: Date.today,
name: "Parking - Provider",
source: "lunchflow"
)
# Should claim the older entry
assert_equal older_entry.id, entry.id
assert_equal "lunchflow_parking", entry.external_id
# Newer entry should remain unclaimed
assert_nil newer_entry.reload.external_id
end
end
end

View File

@@ -16,4 +16,32 @@ class PlaidItemTest < ActiveSupport::TestCase
@plaid_item.destroy
end
end
test "destroys item even when Plaid credentials are invalid" do
error_response = {
"error_code" => "INVALID_API_KEYS",
"error_message" => "invalid client_id or secret provided"
}.to_json
plaid_error = Plaid::ApiError.new(code: 400, response_body: error_response)
@plaid_provider.expects(:remove_item).raises(plaid_error)
assert_difference "PlaidItem.count", -1 do
@plaid_item.destroy
end
end
test "destroys item even when Plaid item not found" do
error_response = {
"error_code" => "ITEM_NOT_FOUND",
"error_message" => "item not found"
}.to_json
plaid_error = Plaid::ApiError.new(code: 400, response_body: error_response)
@plaid_provider.expects(:remove_item).raises(plaid_error)
assert_difference "PlaidItem.count", -1 do
@plaid_item.destroy
end
end
end