1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 23:45:21 +02:00

Apply attribute locks on user edits

This commit is contained in:
Zach Gollwitzer 2025-04-12 22:10:58 -04:00
parent f8e140e39d
commit 77b914150f
17 changed files with 225 additions and 63 deletions

View file

@ -3,9 +3,30 @@ class Account::TransactionsController < ApplicationController
permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] }
def create
@entry = build_entry
if @entry.save
@entry.sync_account_later
@entry.lock_saved_attributes!
@entry.account_transaction.lock!(:tag_ids) if @entry.account_transaction.tags.any?
flash[:notice] = t("account.entries.create.success")
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account) }
format.turbo_stream { stream_redirect_back_or_to account_path(@entry.account) }
end
else
render :new, status: :unprocessable_entity
end
end
def update
if @entry.update(update_entry_params)
@entry.sync_account_later
@entry.lock_saved_attributes!
@entry.account_transaction.lock!(:tag_ids) if @entry.account_transaction.tags.any?
transaction = @entry.account_transaction

View file

@ -37,11 +37,15 @@ module AccountableResource
def create
@account = Current.family.accounts.create_and_sync(account_params.except(:return_to))
@account.lock_saved_attributes!
redirect_to account_params[:return_to].presence || @account, notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize)
end
def update
@account.update_with_sync!(account_params.except(:return_to))
@account.lock_saved_attributes!
redirect_back_or_to @account, notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize)
end

View file

@ -32,6 +32,7 @@ module EntryableResource
if @entry.save
@entry.sync_account_later
@entry.lock_saved_attributes!
flash[:notice] = t("account.entries.create.success")
@ -47,6 +48,8 @@ module EntryableResource
def update
if @entry.update(update_entry_params)
@entry.sync_account_later
@entry.lock_saved_attributes!
@entry.entryable.lock_saved_attributes!
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") }

View file

@ -1,5 +1,5 @@
class Account < ApplicationRecord
include Syncable, Monetizable, Chartable, Linkable, Convertible
include Syncable, Monetizable, Chartable, Linkable, Convertible, Enrichable
validates :name, :balance, :currency, presence: true
@ -127,6 +127,11 @@ class Account < ApplicationRecord
first_entry_date - 1.day
end
def lock_saved_attributes!
super
accountable.lock_saved_attributes!
end
private
def sync_balances
strategy = linked? ? :reverse : :forward

View file

@ -1,5 +1,5 @@
class Account::Entry < ApplicationRecord
include Monetizable, LockableAttributes
include Monetizable, Enrichable
monetize :amount
@ -34,6 +34,11 @@ class Account::Entry < ApplicationRecord
)
}
def lock_saved_attributes!
super
entryable.lock_saved_attributes!
end
def sync_account_later
sync_start_date = [ date_previously_was, date ].compact.min unless destroyed?
account.sync_later(start_date: sync_start_date)
@ -78,6 +83,10 @@ class Account::Entry < ApplicationRecord
all.each do |entry|
bulk_attributes[:entryable_attributes][:id] = entry.entryable_id if bulk_attributes[:entryable_attributes].present?
entry.update! bulk_attributes
entry.lock_saved_attributes!
entry.entryable.lock_saved_attributes!
entry.entryable.lock!(:tag_ids) if entry.account_transaction? && entry.account_transaction.tags.any?
end
end

View file

@ -8,6 +8,8 @@ module Account::Entryable
end
included do
include Enrichable
has_one :entry, as: :entryable, touch: true
scope :with_entry, -> { joins(:entry) }

View file

@ -15,6 +15,21 @@ class Account::TradeBuilder
buildable.save
end
def lock_saved_attributes!
if buildable.is_a?(Transfer)
buildable.inflow_transaction.entry.lock_saved_attributes!
buildable.outflow_transaction.entry.lock_saved_attributes!
else
buildable.lock_saved_attributes!
end
end
def entryable
return nil if buildable.is_a?(Transfer)
buildable.entryable
end
def errors
buildable.errors
end

View file

@ -1,5 +1,5 @@
class Account::Transaction < ApplicationRecord
include Account::Entryable, Transferable, Ruleable, LockableAttributes
include Account::Entryable, Transferable, Ruleable
belongs_to :category, optional: true
belongs_to :merchant, optional: true

View file

@ -9,6 +9,8 @@ module Accountable
end
included do
include Enrichable
has_one :account, as: :accountable, touch: true
end

View file

@ -0,0 +1,51 @@
# Enrichable models can have 1+ of their fields enriched by various
# external sources (i.e. Plaid) or internal sources (i.e. Rules)
#
# This module defines how models should, lock, unlock, and edit attributes
# based on the source of the edit. User edits always take highest precedence.
#
# For example:
#
# If a Rule tells us to set the category to "Groceries", but the user later overrides
# a transaction with a category of "Food", we should not override the category again.
#
module Enrichable
extend ActiveSupport::Concern
InvalidAttributeError = Class.new(StandardError)
included do
scope :enrichable, ->(attrs) {
attrs = Array(attrs).map(&:to_s)
json_condition = attrs.each_with_object({}) { |attr, hash| hash[attr] = true }
where.not(Arel.sql("#{table_name}.locked_attributes ?| array[:keys]"), keys: attrs)
}
end
def locked?(attr)
locked_attributes[attr.to_s].present?
end
def enrichable?(attr)
!locked?(attr)
end
def lock!(attr)
update!(locked_attributes: locked_attributes.merge(attr.to_s => Time.current))
end
def unlock!(attr)
update!(locked_attributes: locked_attributes.except(attr.to_s))
end
def lock_saved_attributes!
saved_changes.keys.reject { |attr| ignored_enrichable_attributes.include?(attr) }.each do |attr|
lock!(attr)
end
end
private
def ignored_enrichable_attributes
%w[updated_at created_at]
end
end

View file

@ -1,29 +0,0 @@
# Marks model attributes as "locked" so Rules and other external data enrichment
# sources know which attributes they can modify.
module LockableAttributes
extend ActiveSupport::Concern
included do
scope :attributes_unlocked, ->(attrs) {
attrs = Array(attrs).map(&:to_s)
json_condition = attrs.each_with_object({}) { |attr, hash| hash[attr] = true }
where.not(Arel.sql("#{table_name}.locked_fields ?| array[:keys]"), keys: attrs)
}
end
def locked?(attr)
locked_fields[attr.to_s] == true
end
def lock!(attr)
update!(locked_fields: locked_fields.merge(attr.to_s => true))
end
def unlock!(attr)
update!(locked_fields: locked_fields.except(attr.to_s))
end
def attribute_unlocked(attr)
!locked?(attr)
end
end

View file

@ -29,7 +29,6 @@ class Transfer < ApplicationRecord
currency: converted_amount.currency.iso_code,
date: date,
name: "Transfer from #{from_account.name}",
entryable: Account::Transaction.new
)
),
outflow_transaction: Account::Transaction.new(
@ -38,7 +37,6 @@ class Transfer < ApplicationRecord
currency: from_account.currency,
date: date,
name: "Transfer to #{to_account.name}",
entryable: Account::Transaction.new
)
),
status: "confirmed"

View file

@ -1,12 +1,32 @@
class CreateDataEnrichments < ActiveRecord::Migration[7.2]
def change
create_table :data_enrichments, id: :uuid do |t|
t.references :enrichable, polymorphic: true, null: false, type: :uuid
t.string :source
t.string :attribute_name
t.jsonb :value
t.timestamps
end
add_column :account_transactions, :locked_fields, :jsonb, default: {}
add_column :account_entries, :locked_fields, :jsonb, default: {}
add_index :data_enrichments, [ :enrichable_id, :enrichable_type, :source, :attribute_name ], unique: true
# Entries
add_column :account_entries, :locked_attributes, :jsonb, default: {}
add_column :account_transactions, :locked_attributes, :jsonb, default: {}
add_column :account_trades, :locked_attributes, :jsonb, default: {}
add_column :account_valuations, :locked_attributes, :jsonb, default: {}
# Accounts
add_column :accounts, :locked_attributes, :jsonb, default: {}
add_column :depositories, :locked_attributes, :jsonb, default: {}
add_column :investments, :locked_attributes, :jsonb, default: {}
add_column :cryptos, :locked_attributes, :jsonb, default: {}
add_column :properties, :locked_attributes, :jsonb, default: {}
add_column :vehicles, :locked_attributes, :jsonb, default: {}
add_column :other_assets, :locked_attributes, :jsonb, default: {}
add_column :credit_cards, :locked_attributes, :jsonb, default: {}
add_column :loans, :locked_attributes, :jsonb, default: {}
add_column :other_liabilities, :locked_attributes, :jsonb, default: {}
end
end

22
db/schema.rb generated
View file

@ -47,7 +47,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_11_191422) do
t.string "plaid_id"
t.datetime "enriched_at"
t.string "enriched_name"
t.jsonb "locked_fields", default: {}
t.jsonb "locked_attributes", default: {}
t.index ["account_id"], name: "index_account_entries_on_account_id"
t.index ["import_id"], name: "index_account_entries_on_import_id"
end
@ -74,6 +74,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_11_191422) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "currency"
t.jsonb "locked_attributes", default: {}
t.index ["security_id"], name: "index_account_trades_on_security_id"
end
@ -82,7 +83,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_11_191422) do
t.datetime "updated_at", null: false
t.uuid "category_id"
t.uuid "merchant_id"
t.jsonb "locked_fields", default: {}
t.jsonb "locked_attributes", default: {}
t.index ["category_id"], name: "index_account_transactions_on_category_id"
t.index ["merchant_id"], name: "index_account_transactions_on_merchant_id"
end
@ -90,6 +91,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_11_191422) do
create_table "account_valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "locked_attributes", default: {}
end
create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@ -109,6 +111,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_11_191422) do
t.boolean "scheduled_for_deletion", default: false
t.datetime "last_synced_at"
t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0"
t.jsonb "locked_attributes", default: {}
t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type"
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type"
@ -217,22 +220,31 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_11_191422) do
t.decimal "apr", precision: 10, scale: 2
t.date "expiration_date"
t.decimal "annual_fee", precision: 10, scale: 2
t.jsonb "locked_attributes", default: {}
end
create_table "cryptos", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "locked_attributes", default: {}
end
create_table "data_enrichments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "enrichable_type", null: false
t.uuid "enrichable_id", null: false
t.string "source"
t.string "attribute_name"
t.jsonb "value"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["enrichable_id", "enrichable_type", "source", "attribute_name"], name: "idx_on_enrichable_id_enrichable_type_source_attribu_5be5f63e08", unique: true
t.index ["enrichable_type", "enrichable_id"], name: "index_data_enrichments_on_enrichable"
end
create_table "depositories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "locked_attributes", default: {}
end
create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@ -354,6 +366,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_11_191422) do
create_table "investments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "locked_attributes", default: {}
end
create_table "invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@ -386,6 +399,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_11_191422) do
t.string "rate_type"
t.decimal "interest_rate", precision: 10, scale: 3
t.integer "term_months"
t.jsonb "locked_attributes", default: {}
end
create_table "merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@ -422,11 +436,13 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_11_191422) do
create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "locked_attributes", default: {}
end
create_table "other_liabilities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "locked_attributes", default: {}
end
create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@ -470,6 +486,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_11_191422) do
t.integer "year_built"
t.integer "area_value"
t.string "area_unit"
t.jsonb "locked_attributes", default: {}
end
create_table "rejected_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@ -678,6 +695,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_04_11_191422) do
t.string "mileage_unit"
t.string "make"
t.string "model"
t.jsonb "locked_attributes", default: {}
end
add_foreign_key "account_balances", "accounts", on_delete: :cascade

View file

@ -12,7 +12,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
assert_no_difference [ "Account::Entry.count", "Account::Trade.count" ] do
patch account_trade_url(@entry), params: {
account_entry: {
currency: "USD",
currency: "EUR",
entryable_attributes: {
id: @entry.entryable_id,
qty: 20,
@ -24,11 +24,17 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
@entry.reload
assert @entry.locked?(:currency)
assert @entry.locked?(:amount)
assert @entry.account_trade.locked?(:qty)
assert @entry.account_trade.locked?(:price)
assert_enqueued_with job: SyncJob
assert_equal 20, @entry.account_trade.qty
assert_equal 20, @entry.account_trade.price
assert_equal "USD", @entry.currency
assert_equal "EUR", @entry.currency
assert_redirected_to account_url(@entry.account)
end
@ -111,6 +117,9 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
created_entry = Account::Entry.order(created_at: :desc).first
assert created_entry.locked?(:currency)
assert created_entry.locked?(:amount)
assert created_entry.amount.negative?
assert_redirected_to @entry.account
end
@ -132,6 +141,12 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
created_entry = Account::Entry.order(created_at: :desc).first
assert created_entry.locked?(:currency)
assert created_entry.locked?(:amount)
assert created_entry.account_trade.locked?(:qty)
assert created_entry.account_trade.locked?(:price)
assert created_entry.amount.positive?
assert created_entry.account_trade.qty.positive?
assert_equal "Entry created", flash[:notice]
@ -156,6 +171,12 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
created_entry = Account::Entry.order(created_at: :desc).first
assert created_entry.locked?(:currency)
assert created_entry.locked?(:amount)
assert created_entry.account_trade.locked?(:qty)
assert created_entry.account_trade.locked?(:price)
assert created_entry.amount.negative?
assert created_entry.account_trade.qty.negative?
assert_equal "Entry created", flash[:notice]

View file

@ -28,6 +28,16 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
end
created_entry = Account::Entry.order(:created_at).last
created_transaction = created_entry.account_transaction
assert created_entry.locked?(:name)
assert created_entry.locked?(:date)
assert created_entry.locked?(:amount)
assert created_entry.locked?(:currency)
assert created_transaction.locked?(:tag_ids)
assert created_transaction.locked?(:category_id)
assert created_transaction.locked?(:merchant_id)
assert_redirected_to account_url(created_entry.account)
assert_equal "Entry created", flash[:notice]
@ -49,8 +59,8 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
entryable_attributes: {
id: @entry.entryable_id,
tag_ids: [ Tag.first.id, Tag.second.id ],
category_id: Category.first.id,
merchant_id: Merchant.first.id
category_id: categories(:subcategory).id,
merchant_id: merchants(:netflix).id
}
}
}
@ -63,11 +73,20 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
assert_equal "USD", @entry.currency
assert_equal -100, @entry.amount
assert_equal [ Tag.first.id, Tag.second.id ], @entry.entryable.tag_ids.sort
assert_equal Category.first.id, @entry.entryable.category_id
assert_equal Merchant.first.id, @entry.entryable.merchant_id
assert_equal categories(:subcategory).id, @entry.entryable.category_id
assert_equal merchants(:netflix).id, @entry.entryable.merchant_id
assert_equal "test notes", @entry.notes
assert_equal false, @entry.excluded
assert @entry.locked?(:name)
assert @entry.locked?(:date)
assert @entry.locked?(:amount)
assert @entry.locked?(:notes)
assert @entry.account_transaction.locked?(:tag_ids)
assert @entry.account_transaction.locked?(:category_id)
assert @entry.account_transaction.locked?(:merchant_id)
assert_equal "Entry updated", flash[:notice]
assert_redirected_to account_url(@entry.account)
assert_enqueued_with(job: SyncJob)
@ -90,15 +109,18 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
end
test "can update many transactions at once" do
transactions = @user.family.entries.account_transactions
transaction_entries = @user.family.entries.account_transactions
new_category = @user.family.categories.create!(name: "New category")
new_merchant = @user.family.merchants.create!(type: "FamilyMerchant", name: "New merchant")
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 0 do
post bulk_update_account_transactions_url, params: {
bulk_update: {
entry_ids: transactions.map(&:id),
date: 1.day.ago.to_date,
category_id: Category.second.id,
merchant_id: Merchant.second.id,
entry_ids: transaction_entries.map(&:id),
date: 5.days.ago.to_date,
category_id: new_category.id,
merchant_id: new_merchant.id,
tag_ids: [ Tag.first.id, Tag.second.id ],
notes: "Updated note"
}
@ -106,14 +128,21 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
end
assert_redirected_to transactions_url
assert_equal "#{transactions.count} transactions updated", flash[:notice]
assert_equal "#{transaction_entries.count} transactions updated", flash[:notice]
transactions.reload.each do |transaction|
assert_equal 1.day.ago.to_date, transaction.date
assert_equal Category.second, transaction.account_transaction.category
assert_equal Merchant.second, transaction.account_transaction.merchant
assert_equal "Updated note", transaction.notes
assert_equal [ Tag.first.id, Tag.second.id ], transaction.entryable.tag_ids.sort
transaction_entries.reload.each do |transaction_entry|
assert_equal 5.days.ago.to_date, transaction_entry.date
assert_equal new_category, transaction_entry.account_transaction.category
assert_equal new_merchant, transaction_entry.account_transaction.merchant
assert_equal "Updated note", transaction_entry.notes
assert_equal [ Tag.first.id, Tag.second.id ], transaction_entry.entryable.tag_ids.sort
assert transaction_entry.locked?(:date)
assert transaction_entry.locked?(:notes)
assert transaction_entry.account_transaction.locked?(:category_id)
assert transaction_entry.account_transaction.locked?(:merchant_id)
assert transaction_entry.account_transaction.locked?(:tag_ids)
end
end
end

View file

@ -1,7 +0,0 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
source: MyString
two:
source: MyString