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:
parent
296dcd7f26
commit
dcf5ab233a
9 changed files with 187 additions and 86 deletions
|
@ -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
|
||||||
|
|
|
@ -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,
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
|
@ -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
2
db/schema.rb
generated
|
@ -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
|
||||||
|
|
|
@ -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
|
91
test/models/plaid_entry/processor_test.rb
Normal file
91
test/models/plaid_entry/processor_test.rb
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue