1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 07:25:19 +02:00

Transaction processor test cases

This commit is contained in:
Zach Gollwitzer 2025-05-23 15:29:04 -04:00
parent 296dcd7f26
commit dcf5ab233a
9 changed files with 187 additions and 86 deletions

View file

@ -1,19 +1,7 @@
# The purpose of this matcher is to auto-match Plaid categories to class PlaidAccount::Transactions::CategoryMatcher
# known internal user categories. Since we allow users to define their own include PlaidAccount::Transactions::CategoryTaxonomy
# categories we cannot directly assign Plaid categories as this would overwrite
# user data and create a confusing experience.
#
# Automated category matching in the Maybe app has a hierarchy:
# 1. Naive string matching via CategoryAliasMatcher
# 2. Rules-based matching set by user
# 3. AI-powered matching (also enabled by user via rules)
#
# This class is simply a FAST and CHEAP way to match categories that are high confidence.
# Edge cases will be handled by user-defined rules.
class Provider::Plaid::CategoryAliasMatcher
include Provider::Plaid::CategoryTaxonomy
def initialize(user_categories) def initialize(user_categories = [])
@user_categories = user_categories @user_categories = user_categories
end end

View file

@ -1,5 +1,5 @@
# https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv # https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv
module Provider::Plaid::CategoryTaxonomy module PlaidAccount::Transactions::CategoryTaxonomy
CATEGORIES_MAP = { CATEGORIES_MAP = {
income: { income: {
classification: :income, classification: :income,

View file

@ -4,12 +4,18 @@ class PlaidAccount::Transactions::Processor
end end
def process def process
PlaidAccount.transaction do # Each entry is processed inside a transaction, but to avoid locking up the DB when
modified_transactions.each do |transaction| # there are hundreds or thousands of transactions, we process them individually.
PlaidEntry::TransactionProcessor.new(transaction, plaid_account: plaid_account).process modified_transactions.find_each do |transaction|
end PlaidEntry::Processor.new(
transaction,
plaid_account: plaid_account,
category_matcher: category_matcher
).process
end
removed_transactions.each do |transaction| PlaidAccount.transaction do
removed_transactions.find_each do |transaction|
remove_plaid_transaction(transaction) remove_plaid_transaction(transaction)
end end
end end
@ -18,6 +24,20 @@ class PlaidAccount::Transactions::Processor
private private
attr_reader :plaid_account attr_reader :plaid_account
def category_matcher
@category_matcher ||= PlaidAccount::Transactions::CategoryMatcher.new(family_categories)
end
def family_categories
@family_categories ||= begin
if account.family.categories.none?
account.family.categories.bootstrap!
end
account.family.categories
end
end
def account def account
plaid_account.account plaid_account.account
end end

View file

@ -1,45 +1,53 @@
class PlaidEntry::TransactionProcessor class PlaidEntry::Processor
# plaid_transaction is the raw hash fetched from Plaid API and converted to JSONB # plaid_transaction is the raw hash fetched from Plaid API and converted to JSONB
def initialize(plaid_transaction, plaid_account:) def initialize(plaid_transaction, plaid_account:, category_matcher:)
@plaid_transaction = plaid_transaction @plaid_transaction = plaid_transaction
@plaid_account = plaid_account @plaid_account = plaid_account
@category_matcher = category_matcher
end end
def process def process
entry = account.entries.find_or_initialize_by(plaid_id: plaid_id) do |e| PlaidAccount.transaction do
e.entryable = Transaction.new entry = account.entries.find_or_initialize_by(plaid_id: plaid_id) do |e|
end e.entryable = Transaction.new
end
entry.enrich_attribute( entry.assign_attributes(
:name, amount: amount,
name, currency: currency,
source: "plaid" date: date
) )
entry.assign_attributes( entry.enrich_attribute(
amount: amount, :name,
currency: currency, name,
date: date
)
if merchant
entry.transaction.enrich_attribute(
:merchant_id,
merchant.id,
source: "plaid" source: "plaid"
) )
if detailed_category
matched_category = category_matcher.match(detailed_category)
if matched_category
entry.transaction.enrich_attribute(
:category_id,
matched_category.id,
source: "plaid"
)
end
end
if merchant
entry.transaction.enrich_attribute(
:merchant_id,
merchant.id,
source: "plaid"
)
end
end end
entry.transaction.assign_attributes(
plaid_category: primary_category,
plaid_category_detailed: detailed_category,
)
entry.save!
end end
private private
attr_reader :plaid_transaction, :plaid_account attr_reader :plaid_transaction, :plaid_account, :category_matcher
def account def account
plaid_account.account plaid_account.account
@ -65,12 +73,8 @@ class PlaidEntry::TransactionProcessor
plaid_transaction["date"] plaid_transaction["date"]
end end
def primary_category
plaid_transaction["personal_finance_category"]["primary"]
end
def detailed_category def detailed_category
plaid_transaction["personal_finance_category"]["detailed"] plaid_transaction.dig("personal_finance_category", "detailed")
end end
def merchant def merchant

View file

@ -43,10 +43,6 @@ class PlaidItem < ApplicationRecord
end end
end end
def build_category_alias_matcher(user_categories)
Provider::Plaid::CategoryAliasMatcher.new(user_categories)
end
def destroy_later def destroy_later
update!(scheduled_for_deletion: true) update!(scheduled_for_deletion: true)
DestroyJob.perform_later(self) DestroyJob.perform_later(self)
@ -107,35 +103,35 @@ class PlaidItem < ApplicationRecord
save! save!
end end
def auto_match_categories! # def auto_match_categories!
if family.categories.none? # if family.categories.none?
family.categories.bootstrap! # family.categories.bootstrap!
end # end
alias_matcher = build_category_alias_matcher(family.categories) # alias_matcher = build_category_alias_matcher(family.categories)
accounts.each do |account| # accounts.each do |account|
matchable_transactions = account.transactions # matchable_transactions = account.transactions
.where(category_id: nil) # .where(category_id: nil)
.where.not(plaid_category: nil) # .where.not(plaid_category: nil)
.enrichable(:category_id) # .enrichable(:category_id)
matchable_transactions.each do |transaction| # matchable_transactions.each do |transaction|
category = alias_matcher.match(transaction.plaid_category_detailed) # category = alias_matcher.match(transaction.plaid_category_detailed)
if category.present? # if category.present?
# Matcher could either return a string or a Category object # # Matcher could either return a string or a Category object
user_category = if category.is_a?(String) # user_category = if category.is_a?(String)
family.categories.find_or_create_by!(name: category) # family.categories.find_or_create_by!(name: category)
else # else
category # category
end # end
transaction.enrich_attribute(:category_id, user_category.id, source: "plaid") # transaction.enrich_attribute(:category_id, user_category.id, source: "plaid")
end # end
end # end
end # end
end # end
def supports_product?(product) def supports_product?(product)
supported_products.include?(product) supported_products.include?(product)

View file

@ -16,5 +16,9 @@ class AddRawPayloadsToPlaidAccounts < ActiveRecord::Migration[7.2]
change_column_null :plaid_accounts, :currency, false change_column_null :plaid_accounts, :currency, false
change_column_null :plaid_accounts, :name, false change_column_null :plaid_accounts, :name, false
add_index :plaid_accounts, :plaid_id, unique: true add_index :plaid_accounts, :plaid_id, unique: true
# No longer need to store on transaction model because it is stored in raw_transactions_payload
remove_column :transactions, :plaid_category
remove_column :transactions, :plaid_category_detailed
end end
end end

2
db/schema.rb generated
View file

@ -645,8 +645,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_23_131455) do
t.uuid "category_id" t.uuid "category_id"
t.uuid "merchant_id" t.uuid "merchant_id"
t.jsonb "locked_attributes", default: {} t.jsonb "locked_attributes", default: {}
t.string "plaid_category"
t.string "plaid_category_detailed"
t.index ["category_id"], name: "index_transactions_on_category_id" t.index ["category_id"], name: "index_transactions_on_category_id"
t.index ["merchant_id"], name: "index_transactions_on_merchant_id" t.index ["merchant_id"], name: "index_transactions_on_merchant_id"
end end

View file

@ -1,6 +1,6 @@
require "test_helper" require "test_helper"
class Provider::Plaid::CategoryAliasMatcherTest < ActiveSupport::TestCase class PlaidAccount::Transactions::CategoryMatcherTest < ActiveSupport::TestCase
setup do setup do
@family = families(:empty) @family = families(:empty)
@ -32,7 +32,7 @@ class Provider::Plaid::CategoryAliasMatcherTest < ActiveSupport::TestCase
@giving = @family.categories.create!(name: "Giving") @giving = @family.categories.create!(name: "Giving")
@matcher = Provider::Plaid::CategoryAliasMatcher.new(@family.categories) @matcher = PlaidAccount::Transactions::CategoryMatcher.new(@family.categories)
end end
test "matches expense categories" do test "matches expense categories" do

View file

@ -0,0 +1,91 @@
require "test_helper"
class PlaidEntry::ProcessorTest < ActiveSupport::TestCase
setup do
@plaid_account = plaid_accounts(:one)
@category_matcher = mock("PlaidAccount::Transactions::CategoryMatcher")
end
test "creates new entry transaction" do
plaid_transaction = {
"transaction_id" => "123",
"merchant_name" => "Amazon", # this is used for merchant and entry name
"amount" => 100,
"date" => Date.current,
"iso_currency_code" => "USD",
"personal_finance_category" => {
"detailed" => "Food"
},
"merchant_entity_id" => "123"
}
@category_matcher.expects(:match).with("Food").returns(categories(:food_and_drink))
processor = PlaidEntry::Processor.new(
plaid_transaction,
plaid_account: @plaid_account,
category_matcher: @category_matcher
)
assert_difference [ "Entry.count", "Transaction.count", "ProviderMerchant.count" ], 1 do
processor.process
end
entry = Entry.order(created_at: :desc).first
assert_equal 100, entry.amount
assert_equal "USD", entry.currency
assert_equal Date.current, entry.date
assert_equal "Amazon", entry.name
assert_equal categories(:food_and_drink).id, entry.transaction.category_id
provider_merchant = ProviderMerchant.order(created_at: :desc).first
assert_equal "Amazon", provider_merchant.name
end
test "updates existing entry transaction" do
existing_plaid_id = "existing_plaid_id"
plaid_transaction = {
"transaction_id" => existing_plaid_id,
"merchant_name" => "Amazon", # this is used for merchant and entry name
"amount" => 200, # Changed amount will be updated
"date" => 1.day.ago.to_date, # Changed date will be updated
"iso_currency_code" => "USD",
"personal_finance_category" => {
"detailed" => "Food"
}
}
@category_matcher.expects(:match).with("Food").returns(categories(:food_and_drink))
# Create an existing entry
@plaid_account.account.entries.create!(
plaid_id: existing_plaid_id,
amount: 100,
currency: "USD",
date: Date.current,
name: "Amazon",
entryable: Transaction.new
)
processor = PlaidEntry::Processor.new(
plaid_transaction,
plaid_account: @plaid_account,
category_matcher: @category_matcher
)
assert_no_difference [ "Entry.count", "Transaction.count", "ProviderMerchant.count" ] do
processor.process
end
entry = Entry.order(created_at: :desc).first
assert_equal 200, entry.amount
assert_equal "USD", entry.currency
assert_equal 1.day.ago.to_date, entry.date
assert_equal "Amazon", entry.name
assert_equal categories(:food_and_drink).id, entry.transaction.category_id
end
end