Files
sure/app/models/transfer/creator.rb
T
William Wei MingandCursor 35c0b08f11 Add tags support for transfer transactions (#2921)
* Add tags support for transfer transactions

Expose TagSelect on transfer create/edit so users can classify
fund movements; apply the same family-scoped tags to both sides.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Require annotate permission on both transfer sides for tags

Prevent tagging a read-only destination transaction when the user
only has write access on the outflow account.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore transfer tag selections on create form errors

* Localize transfer create validation error messages

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:47:34 +02:00

168 lines
5.0 KiB
Ruby

class Transfer::Creator
def initialize(family:, source_account_id:, destination_account_id:, date:, amount:, exchange_rate: nil, source_fee_amount: nil, destination_fee_amount: nil, tag_ids: nil)
@family = family
@source_account = family.accounts.find(source_account_id) # early throw if not found
@destination_account = family.accounts.find(destination_account_id) # early throw if not found
@date = date
@amount = amount.to_d
@source_fee_amount = source_fee_amount.to_d
@destination_fee_amount = destination_fee_amount.to_d
@tag_ids = Array(tag_ids).reject(&:blank?)
if exchange_rate.present?
rate_value = exchange_rate.to_d
raise ArgumentError, "exchange_rate must be greater than 0" unless rate_value > 0
@exchange_rate = rate_value
else
@exchange_rate = nil
end
end
def create
raise ArgumentError, "source_fee_amount must be non-negative" if source_fee_amount.negative?
raise ArgumentError, "destination_fee_amount must be non-negative" if destination_fee_amount.negative?
transfer = Transfer.new(
inflow_transaction: inflow_transaction,
outflow_transaction: outflow_transaction,
status: "confirmed",
amount: amount
)
Transfer.transaction do
if source_fee_amount > 0
transfer.fee_transactions << build_source_fee_transaction
end
if destination_fee_amount > 0
transfer.fee_transactions << build_destination_fee_transaction
end
transfer.save!
apply_tags!(transfer) if tag_ids.any?
end
source_account.sync_later
destination_account.sync_later
transfer
end
private
attr_reader :family, :source_account, :destination_account, :date, :amount, :exchange_rate, :source_fee_amount, :destination_fee_amount, :tag_ids
def apply_tags!(transfer)
resolved_ids = family.tags.where(id: tag_ids).pluck(:id)
return if resolved_ids.empty?
[ transfer.outflow_transaction, transfer.inflow_transaction ].each do |transaction|
transaction.tag_ids = resolved_ids
end
end
def outflow_transaction
name = "#{name_prefix} to #{destination_account.name}"
kind = outflow_transaction_kind
Transaction.new(
kind: kind,
category: (investment_contributions_category if kind == "investment_contribution"),
entry: source_account.entries.build(
amount: amount,
currency: source_account.currency,
date: date,
name: name,
user_modified: true,
)
)
end
def investment_contributions_category
source_account.family.investment_contributions_category
end
def inflow_transaction
name = "#{name_prefix} from #{source_account.name}"
net_inflow = inflow_converted_amount
Transaction.new(
kind: "funds_movement",
entry: destination_account.entries.build(
amount: net_inflow * -1,
currency: destination_account.currency,
date: date,
name: name,
user_modified: true,
)
)
end
def build_source_fee_transaction
fee_category = find_or_create_fees_category(source_account.family)
Transaction.new(
kind: "standard",
category: fee_category,
entry: source_account.entries.build(
amount: source_fee_amount,
currency: source_account.currency,
date: date,
name: "Transfer fee — #{name_prefix} to #{destination_account.name}",
)
)
end
def build_destination_fee_transaction
fee_category = find_or_create_fees_category(destination_account.family)
Transaction.new(
kind: "standard",
category: fee_category,
entry: destination_account.entries.build(
amount: destination_fee_amount,
currency: destination_account.currency,
date: date,
name: "Transfer fee — #{name_prefix} from #{source_account.name}",
)
)
end
def find_or_create_fees_category(family)
family.categories.find_or_create_by!(name: I18n.t("models.category.defaults.fees"))
end
def inflow_converted_amount
Money.new(amount.abs, source_account.currency)
.exchange_to(
destination_account.currency,
date: date,
custom_rate: exchange_rate
).amount
end
def outflow_transaction_kind
if destination_account.loan?
"loan_payment"
elsif destination_account.liability?
"cc_payment"
elsif destination_is_investment? && !source_is_investment?
"investment_contribution"
else
"funds_movement"
end
end
def destination_is_investment?
destination_account.investment? || destination_account.crypto?
end
def source_is_investment?
source_account.investment? || source_account.crypto?
end
def name_prefix
if destination_account.liability?
"Payment"
else
"Transfer"
end
end
end