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

Basic transfer creator

This commit is contained in:
Zach Gollwitzer 2025-06-17 11:03:16 -04:00
parent 12cbab035c
commit ae4a4e22b2
9 changed files with 315 additions and 22 deletions

View file

@ -1,4 +1,6 @@
class TransfersController < ApplicationController
include StreamExtensions
before_action :set_transfer, only: %i[destroy show update]
def new
@ -10,25 +12,19 @@ class TransfersController < ApplicationController
end
def create
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
@transfer = Transfer.from_accounts(
from_account: from_account,
to_account: to_account,
@transfer = Transfer::Creator.new(
family: Current.family,
source_account_id: transfer_params[:from_account_id],
destination_account_id: transfer_params[:to_account_id],
date: transfer_params[:date],
amount: transfer_params[:amount].to_d
)
if @transfer.save
@transfer.sync_account_later
flash[:notice] = t(".success")
).create
if @transfer.persisted?
success_message = "Transfer created"
respond_to do |format|
format.html { redirect_back_or_to transactions_path }
redirect_target_url = request.referer || transactions_path
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
format.html { redirect_back_or_to transactions_path, notice: success_message }
format.turbo_stream { stream_redirect_back_or_to transactions_path, notice: success_message }
end
else
render :new, status: :unprocessable_entity

View file

@ -0,0 +1,12 @@
class Trade::Creator
def initialize(attrs)
@attrs = attrs
end
def create
# TODO
end
private
attr_reader :attrs
end

View file

@ -10,10 +10,11 @@ class Transaction < ApplicationRecord
accepts_nested_attributes_for :taggings, allow_destroy: true
enum :kind, {
standard: "standard",
transfer: "transfer",
loan_payment: "loan_payment",
one_time: "one_time"
standard: "standard", # A regular transaction, included in budget analytics
transfer: "transfer", # Movement of funds, excluded from budget analytics
payment: "payment", # A CC or Other payment, excluded from budget analytics (CC payments offset the sum of expense transactions)
loan_payment: "loan_payment", # A payment to a Loan account, treated as an expense in budgets
one_time: "one_time" # A one-time expense/income, excluded from budget analytics
}
class << self

View file

@ -22,8 +22,17 @@ class Transfer < ApplicationRecord
Money.new(amount.abs, from_account.currency)
end
outflow_kind = if to_account&.accountable_type == "Loan"
"loan_payment"
elsif to_account&.liability?
"payment"
else
"transfer"
end
new(
inflow_transaction: Transaction.new(
kind: "transfer",
entry: to_account.entries.build(
amount: converted_amount.amount.abs * -1,
currency: converted_amount.currency.iso_code,
@ -32,6 +41,7 @@ class Transfer < ApplicationRecord
)
),
outflow_transaction: Transaction.new(
kind: outflow_kind,
entry: from_account.entries.build(
amount: amount.abs,
currency: from_account.currency,
@ -89,6 +99,24 @@ class Transfer < ApplicationRecord
to_account&.liability?
end
def loan_payment?
outflow_transaction&.kind == "loan_payment"
end
def liability_payment?
outflow_transaction&.kind == "payment"
end
def regular_transfer?
outflow_transaction&.kind == "transfer"
end
def transfer_type
return "loan_payment" if loan_payment?
return "liability_payment" if liability_payment?
"transfer"
end
def categorizable?
to_account&.accountable_type == "Loan"
end

View file

@ -0,0 +1,85 @@
class Transfer::Creator
def initialize(family:, source_account_id:, destination_account_id:, date:, amount:)
@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
end
def create
transfer = Transfer.new(
inflow_transaction: inflow_transaction,
outflow_transaction: outflow_transaction,
status: "confirmed"
)
if transfer.save
source_account.sync_later
destination_account.sync_later
end
transfer
end
private
attr_reader :family, :source_account, :destination_account, :date, :amount
def outflow_transaction
name = "#{name_prefix} to #{destination_account.name}"
Transaction.new(
kind: outflow_transaction_kind,
entry: source_account.entries.build(
amount: amount.abs,
currency: source_account.currency,
date: date,
name: name,
)
)
end
def inflow_transaction
name = "#{name_prefix} from #{source_account.name}"
Transaction.new(
kind: "transfer",
entry: destination_account.entries.build(
amount: inflow_converted_money.amount.abs * -1,
currency: destination_account.currency,
date: date,
name: name,
)
)
end
# If destination account has different currency, its transaction should show up as converted
# Future improvement: instead of a 1:1 conversion fallback, add a UI/UX flow for missing rates
def inflow_converted_money
Money.new(amount.abs, source_account.currency)
.exchange_to(
destination_account.currency,
date: date,
fallback_rate: 1.0
)
end
# The "expense" side of a transfer is treated different in analytics based on where it goes.
def outflow_transaction_kind
if destination_account.loan?
"loan_payment"
elsif destination_account.liability?
"payment"
else
"transfer"
end
end
def name_prefix
if destination_account.liability?
"Payment"
else
"Transfer"
end
end
end

4
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_06_10_181219) do
ActiveRecord::Schema[7.2].define(version: 2025_06_16_183654) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -648,7 +648,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_10_181219) do
t.uuid "category_id"
t.uuid "merchant_id"
t.jsonb "locked_attributes", default: {}
t.string "kind", default: "standard", null: false
t.index ["category_id"], name: "index_transactions_on_category_id"
t.index ["kind"], name: "index_transactions_on_kind"
t.index ["merchant_id"], name: "index_transactions_on_merchant_id"
end

View file

@ -2,5 +2,7 @@ one:
category: food_and_drink
merchant: amazon
transfer_out: { }
transfer_in: { }
transfer_out:
kind: payment
transfer_in:
kind: transfer

View file

@ -0,0 +1,166 @@
require "test_helper"
class Transfer::CreatorTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@source_account = accounts(:depository)
@destination_account = accounts(:investment)
@date = Date.current
@amount = 100
end
test "creates basic transfer" do
creator = Transfer::Creator.new(
family: @family,
source_account_id: @source_account.id,
destination_account_id: @destination_account.id,
date: @date,
amount: @amount
)
transfer = creator.create
assert transfer.persisted?
assert_equal "confirmed", transfer.status
assert transfer.regular_transfer?
assert_equal "transfer", transfer.transfer_type
# Verify outflow transaction (from source account)
outflow = transfer.outflow_transaction
assert_equal "transfer", outflow.kind
assert_equal @amount, outflow.entry.amount
assert_equal @source_account.currency, outflow.entry.currency
assert_equal "Transfer to #{@destination_account.name}", outflow.entry.name
# Verify inflow transaction (to destination account)
inflow = transfer.inflow_transaction
assert_equal "transfer", inflow.kind
assert_equal(@amount * -1, inflow.entry.amount)
assert_equal @destination_account.currency, inflow.entry.currency
assert_equal "Transfer from #{@source_account.name}", inflow.entry.name
end
test "creates multi-currency transfer" do
# Use crypto account which has USD currency but different from source
crypto_account = accounts(:crypto)
creator = Transfer::Creator.new(
family: @family,
source_account_id: @source_account.id,
destination_account_id: crypto_account.id,
date: @date,
amount: @amount
)
transfer = creator.create
assert transfer.persisted?
assert transfer.regular_transfer?
assert_equal "transfer", transfer.transfer_type
# Verify outflow transaction
outflow = transfer.outflow_transaction
assert_equal "transfer", outflow.kind
assert_equal "Transfer to #{crypto_account.name}", outflow.entry.name
# Verify inflow transaction with currency handling
inflow = transfer.inflow_transaction
assert_equal "transfer", inflow.kind
assert_equal "Transfer from #{@source_account.name}", inflow.entry.name
assert_equal crypto_account.currency, inflow.entry.currency
end
test "creates loan payment" do
loan_account = accounts(:loan)
creator = Transfer::Creator.new(
family: @family,
source_account_id: @source_account.id,
destination_account_id: loan_account.id,
date: @date,
amount: @amount
)
transfer = creator.create
assert transfer.persisted?
assert transfer.loan_payment?
assert_equal "loan_payment", transfer.transfer_type
# Verify outflow transaction is marked as loan payment
outflow = transfer.outflow_transaction
assert_equal "loan_payment", outflow.kind
assert_equal "Payment to #{loan_account.name}", outflow.entry.name
# Verify inflow transaction
inflow = transfer.inflow_transaction
assert_equal "transfer", inflow.kind
assert_equal "Payment from #{@source_account.name}", inflow.entry.name
end
test "creates credit card payment" do
credit_card_account = accounts(:credit_card)
creator = Transfer::Creator.new(
family: @family,
source_account_id: @source_account.id,
destination_account_id: credit_card_account.id,
date: @date,
amount: @amount
)
transfer = creator.create
assert transfer.persisted?
assert transfer.liability_payment?
assert_equal "liability_payment", transfer.transfer_type
# Verify outflow transaction is marked as payment for liability
outflow = transfer.outflow_transaction
assert_equal "payment", outflow.kind
assert_equal "Payment to #{credit_card_account.name}", outflow.entry.name
# Verify inflow transaction
inflow = transfer.inflow_transaction
assert_equal "transfer", inflow.kind
assert_equal "Payment from #{@source_account.name}", inflow.entry.name
end
test "raises error when source account ID is invalid" do
assert_raises(ActiveRecord::RecordNotFound) do
Transfer::Creator.new(
family: @family,
source_account_id: 99999,
destination_account_id: @destination_account.id,
date: @date,
amount: @amount
)
end
end
test "raises error when destination account ID is invalid" do
assert_raises(ActiveRecord::RecordNotFound) do
Transfer::Creator.new(
family: @family,
source_account_id: @source_account.id,
destination_account_id: 99999,
date: @date,
amount: @amount
)
end
end
test "raises error when source account belongs to different family" do
other_family = families(:empty)
assert_raises(ActiveRecord::RecordNotFound) do
Transfer::Creator.new(
family: other_family,
source_account_id: @source_account.id,
destination_account_id: @destination_account.id,
date: @date,
amount: @amount
)
end
end
end

View file

@ -203,6 +203,7 @@ class TransactionsTest < ApplicationSystemTestCase
inflow_entry = create_transaction("inflow", 1.day.ago.to_date, -500, account: investment_account)
@user.family.auto_match_transfers!
visit transactions_url
within "#entry-group-" + Date.current.to_s + "-totals" do
assert_text "-$100.00" # transaction eleven from setup
end