Compare commits

...

13 Commits

Author SHA1 Message Date
Zach Gollwitzer
cea90252c8 Bump to v0.1.0-alpha.9
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-07-05 14:16:41 -04:00
Zach Gollwitzer
36cccefb2a Update docker compose example with fixed storage volume (#950) 2024-07-05 14:16:02 -04:00
Tony Vincent
cc6bf6e961 Enable syncing all accounts in one click (#948)
* Enable syncing all accounts on one click

* Remove argument to sync_later method call

* Add partial for sync all accounts button

* Redirect back if possible when syncing all accounts
2024-07-05 07:36:18 -04:00
Tony Vincent
48092cb704 Enque account sync job after creating transfer (#946) 2024-07-04 06:57:26 -04:00
Luke Hurst
cf23453d93 Fix bug where transactions were duplicated in import confirm (#941)
* Fix bug where transactions were duplicated in import confirm

Signed-off-by: Luke Hurst <hurstlj@umich.edu>

* Update imports_test.rb

Signed-off-by: Luke Hurst <hurstlj@umich.edu>

---------

Signed-off-by: Luke Hurst <hurstlj@umich.edu>
2024-07-03 17:44:35 -04:00
Tony Vincent
f1d0a62ac7 Enable updating Account::Entry#amount (#942)
- Enable updating transaction type income/expense
- Enable updating transaction amount
2024-07-03 15:54:05 -04:00
Zach Gollwitzer
3089e3c81d Remove custom currency styling, use default formatter (#937) 2024-07-02 07:27:19 -04:00
dependabot[bot]
0593d8fb7e Bump rails from 9e370f0 to df02832 (#930)
Bumps [rails](https://github.com/rails/rails) from `9e370f0` to `df02832`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](9e370f0243...df02832784)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 11:24:51 -04:00
dependabot[bot]
a8ea207d47 Bump good_job from 3.29.4 to 3.29.5 (#929)
Bumps [good_job](https://github.com/bensheldon/good_job) from 3.29.4 to 3.29.5.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v3.29.4...v3.29.5)

---
updated-dependencies:
- dependency-name: good_job
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 11:21:09 -04:00
dependabot[bot]
c225eb6d03 Bump aws-sdk-s3 from 1.152.3 to 1.155.0 (#928)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.152.3 to 1.155.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 11:20:33 -04:00
dependabot[bot]
9b148316bc Bump pagy from 8.4.5 to 8.6.1 (#932)
Bumps [pagy](https://github.com/ddnexus/pagy) from 8.4.5 to 8.6.1.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/8.4.5...8.6.1)

---
updated-dependencies:
- dependency-name: pagy
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 11:19:49 -04:00
dependabot[bot]
8e7fcfd0b4 Bump sentry-ruby from 5.17.3 to 5.18.0 (#933)
Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 5.17.3 to 5.18.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/5.17.3...5.18.0)

---
updated-dependencies:
- dependency-name: sentry-ruby
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 11:19:21 -04:00
Zach Gollwitzer
c3314e62d1 Account::Entry Delegated Type (namespace updates part 7) (#923)
* Initial entryable models

* Update transfer and tests

* Update transaction controllers and tests

* Update sync process to use new entries model

* Get dashboard working again

* Update transfers, imports, and accounts to use Account::Entry

* Update system tests

* Consolidate transaction management into entries controller

* Add permitted partial key helper

* Move account transactions list to entries controller

* Delegate transaction entries search

* Move transfer relation to entry

* Update bulk transaction management flows to use entries

* Remove test code

* Test fix attempt

* Update demo data script

* Consolidate remaining transaction partials to entries

* Consolidate valuations controller to entries controller

* Lint fix

* Remove unused files, additional cleanup

* Add back valuation creation

* Make migrations fully reversible

* Stale routes cleanup

* Migrations reversible fix

* Move types to entryable concern

* Fix search when no entries found

* Remove more unused code
2024-07-01 10:49:43 -04:00
117 changed files with 2230 additions and 1599 deletions

View File

@@ -7,7 +7,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 9e370f0243870b39ea9801eeb95498f3a0d7bd83
revision: df028327844b6564be8d09075b52288260d4da42
branch: 7-2-stable
specs:
actioncable (7.2.0.beta2)
@@ -76,7 +76,7 @@ GIT
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger
logger (>= 1.4.2)
minitest (>= 5.1)
tzinfo (~> 2.0, >= 2.0.5)
rails (7.2.0.beta2)
@@ -109,17 +109,17 @@ GEM
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.944.0)
aws-sdk-core (3.197.0)
aws-partitions (1.949.0)
aws-sdk-core (3.200.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.84.0)
aws-sdk-core (~> 3, >= 3.197.0)
aws-sdk-kms (1.87.0)
aws-sdk-core (~> 3, >= 3.199.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.152.3)
aws-sdk-core (~> 3, >= 3.197.0)
aws-sdk-s3 (1.155.0)
aws-sdk-core (~> 3, >= 3.199.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8)
aws-sigv4 (1.8.0)
@@ -190,7 +190,7 @@ GEM
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (3.29.4)
good_job (3.29.5)
activejob (>= 6.0.0)
activerecord (>= 6.0.0)
concurrent-ruby (>= 1.0.2)
@@ -253,7 +253,7 @@ GEM
matrix (0.4.2)
mini_magick (4.12.0)
mini_mime (1.1.5)
minitest (5.24.0)
minitest (5.24.1)
mocha (2.4.0)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
@@ -284,7 +284,7 @@ GEM
octokit (9.1.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (8.4.5)
pagy (8.6.1)
parallel (1.24.0)
parser (3.3.1.0)
ast (~> 2.4.1)
@@ -385,10 +385,10 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sentry-rails (5.17.3)
sentry-rails (5.18.0)
railties (>= 5.0)
sentry-ruby (~> 5.17.3)
sentry-ruby (5.17.3)
sentry-ruby (~> 5.18.0)
sentry-ruby (5.18.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
simplecov (0.22.0)

View File

@@ -0,0 +1,105 @@
class Account::EntriesController < ApplicationController
layout "with_sidebar"
before_action :set_account
before_action :set_entry, only: %i[ edit update show destroy ]
def transactions
@transaction_entries = @account.entries.account_transactions.reverse_chronological
end
def valuations
@valuation_entries = @account.entries.account_valuations.reverse_chronological
end
def new
@entry = @account.entries.build.tap do |entry|
if params[:entryable_type]
entry.entryable = Account::Entryable.from_type(params[:entryable_type]).new
else
entry.entryable = Account::Valuation.new
end
end
end
def create
@entry = @account.entries.build(entry_params_with_defaults(entry_params))
if @entry.save
@entry.sync_account_later
redirect_to account_path(@account), notice: t(".success", name: @entry.entryable_name_short.upcase_first)
else
# TODO: this is not an ideal way to handle errors and should eventually be improved.
# See: https://github.com/hotwired/turbo-rails/pull/367
flash[:error] = @entry.errors.full_messages.to_sentence
redirect_to account_path(@account)
end
end
def edit
end
def update
@entry.assign_attributes entry_params
@entry.amount = amount if nature.present?
@entry.save!
@entry.sync_account_later
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
end
end
def show
end
def destroy
@entry.destroy!
@entry.sync_account_later
redirect_back_or_to account_url(@entry.account), notice: t(".success")
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def set_entry
@entry = @account.entries.find(params[:id])
end
def permitted_entryable_attributes
entryable_type = @entry ? @entry.entryable_class.to_s : params[:account_entry][:entryable_type]
case entryable_type
when "Account::Transaction"
[ :id, :notes, :excluded, :category_id, :merchant_id, tag_ids: [] ]
else
[ :id ]
end
end
def entry_params
params.require(:account_entry)
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: permitted_entryable_attributes)
end
def amount
if nature.income?
entry_params[:amount].to_d.abs * -1
else
entry_params[:amount].to_d.abs
end
end
def nature
params[:account_entry][:nature].to_s.inquiry
end
# entryable_type is required here because Rails expects both of these params in this exact order (potential upstream bug)
def entry_params_with_defaults(params)
params.with_defaults(entryable_type: params[:entryable_type], entryable_attributes: {})
end
end

View File

@@ -1,22 +0,0 @@
class Account::Transaction::RowsController < ApplicationController
before_action :set_transaction, only: %i[ show update ]
def show
end
def update
@transaction.update! transaction_params
redirect_to account_transaction_row_path(@transaction.account, @transaction)
end
private
def transaction_params
params.require(:transaction).permit(:category_id)
end
def set_transaction
@transaction = Current.family.accounts.find(params[:account_id]).transactions.find(params[:transaction_id])
end
end

View File

@@ -1,6 +0,0 @@
class Account::Transaction::RulesController < ApplicationController
layout "with_sidebar"
def index
end
end

View File

@@ -1,47 +0,0 @@
class Account::TransactionsController < ApplicationController
layout "with_sidebar"
before_action :set_account
before_action :set_transaction, only: %i[ show update destroy ]
def index
@transactions = @account.transactions.ordered
end
def show
end
def update
@transaction.update! transaction_params
@transaction.sync_account_later
respond_to do |format|
format.html { redirect_back_or_to account_transaction_path(@account, @transaction), notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.replace(@transaction) }
end
end
def destroy
@transaction.destroy!
@transaction.sync_account_later
redirect_back_or_to account_url(@transaction.account), notice: t(".success")
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def set_transaction
@transaction = @account.transactions.find(params[:id])
end
def search_params
params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: [])
end
def transaction_params
params.require(:account_transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id, tag_ids: [])
end
end

View File

@@ -18,6 +18,7 @@ class Account::TransfersController < ApplicationController
name: transfer_params[:name]
if @transfer.save
@transfer.entries.each(&:sync_account_later)
redirect_to transactions_path, notice: t(".success")
else
# TODO: this is not an ideal way to handle errors and should eventually be improved.

View File

@@ -1,61 +0,0 @@
class Account::ValuationsController < ApplicationController
before_action :set_account
before_action :set_valuation, only: %i[ show edit update destroy ]
def new
@valuation = @account.valuations.new
end
def show
end
def create
@valuation = @account.valuations.build(valuation_params)
if @valuation.save
@valuation.sync_account_later
redirect_to account_path(@account), notice: "Valuation created"
else
# TODO: this is not an ideal way to handle errors and should eventually be improved.
# See: https://github.com/hotwired/turbo-rails/pull/367
flash[:error] = @valuation.errors.full_messages.to_sentence
redirect_to account_path(@account)
end
end
def edit
end
def update
if @valuation.update(valuation_params)
@valuation.sync_account_later
redirect_to account_path(@account), notice: t(".success")
else
# TODO: this is not an ideal way to handle errors and should eventually be improved.
# See: https://github.com/hotwired/turbo-rails/pull/367
flash[:error] = @valuation.errors.full_messages.to_sentence
redirect_to account_path(@account)
end
end
def destroy
@valuation.destroy!
@valuation.sync_account_later
redirect_to account_path(@account), notice: t(".success")
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def set_valuation
@valuation = @account.valuations.find(params[:id])
end
def valuation_params
params.require(:account_valuation).permit(:date, :value, :currency)
end
end

View File

@@ -69,6 +69,22 @@ class AccountsController < ApplicationController
redirect_to account_path(@account), notice: t(".success")
end
def sync_all
synced_accounts_count = 0
Current.family.accounts.each do |account|
next unless account.can_sync?
account.sync_later
synced_accounts_count += 1
end
if synced_accounts_count > 0
redirect_back_or_to accounts_path, notice: t(".success", count: synced_accounts_count)
else
redirect_back_or_to accounts_path, alert: t(".no_accounts_to_sync")
end
end
private
def set_account

View File

@@ -21,7 +21,7 @@ class PagesController < ApplicationController
@accounts = Current.family.accounts
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
@transactions = Current.family.transactions.limit(6).order(date: :desc)
@transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological
# TODO: Placeholders for trendlines
placeholder_series_data = 10.times.map do |i|

View File

@@ -3,8 +3,8 @@ class TransactionsController < ApplicationController
def index
@q = search_params
result = Current.family.transactions.search(@q).ordered
@pagy, @transactions = pagy(result, items: params[:per_page] || "10")
result = Current.family.entries.account_transactions.search(@q).reverse_chronological
@pagy, @transaction_entries = pagy(result, items: params[:per_page] || "50")
@totals = {
count: result.select { |t| t.currency == Current.family.currency }.count,
@@ -14,25 +14,26 @@ class TransactionsController < ApplicationController
end
def new
@transaction = Account::Transaction.new.tap do |txn|
@entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e|
if params[:account_id]
txn.account = Current.family.accounts.find(params[:account_id])
e.account = Current.family.accounts.find(params[:account_id])
end
end
end
def create
@transaction = Current.family.accounts
.find(params[:transaction][:account_id])
.transactions
.create!(transaction_params.merge(amount: amount))
@entry = Current.family
.accounts
.find(params[:account_entry][:account_id])
.entries
.create!(transaction_entry_params.merge(amount: amount))
@transaction.sync_account_later
redirect_back_or_to account_path(@transaction.account), notice: t(".success")
@entry.sync_account_later
redirect_back_or_to account_path(@entry.account), notice: t(".success")
end
def bulk_delete
destroyed = Current.family.transactions.destroy_by(id: bulk_delete_params[:transaction_ids])
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
end
@@ -40,19 +41,18 @@ class TransactionsController < ApplicationController
end
def bulk_update
transactions = Current.family.transactions.where(id: bulk_update_params[:transaction_ids])
if transactions.update_all(bulk_update_params.except(:transaction_ids).to_h.compact_blank!)
redirect_back_or_to transactions_url, notice: t(".success", count: transactions.count)
else
flash.now[:error] = t(".failure")
render :index, status: :unprocessable_entity
end
updated = Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.bulk_update!(bulk_update_params)
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
end
def mark_transfers
Current.family
.transactions
.where(id: bulk_update_params[:transaction_ids])
.entries
.where(id: bulk_update_params[:entry_ids])
.mark_transfers!
redirect_back_or_to transactions_url, notice: t(".success")
@@ -60,40 +60,45 @@ class TransactionsController < ApplicationController
def unmark_transfers
Current.family
.transactions
.where(id: bulk_update_params[:transaction_ids])
.entries
.where(id: bulk_update_params[:entry_ids])
.update_all marked_as_transfer: false
redirect_back_or_to transactions_url, notice: t(".success")
end
def rules
end
private
def amount
if nature.income?
transaction_params[:amount].to_d * -1
transaction_entry_params[:amount].to_d * -1
else
transaction_params[:amount].to_d
transaction_entry_params[:amount].to_d
end
end
def nature
params[:transaction][:nature].to_s.inquiry
params[:account_entry][:nature].to_s.inquiry
end
def bulk_delete_params
params.require(:bulk_delete).permit(transaction_ids: [])
params.require(:bulk_delete).permit(entry_ids: [])
end
def bulk_update_params
params.require(:bulk_update).permit(:date, :notes, :excluded, :category_id, :merchant_id, transaction_ids: [])
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
end
def search_params
params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: [])
end
def transaction_params
params.require(:transaction).permit(:name, :date, :amount, :currency, :category_id, tag_ids: [])
def transaction_entry_params
params.require(:account_entry)
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: [ :category_id ])
.with_defaults(entryable_type: "Account::Transaction", entryable_attributes: {})
end
end

View File

@@ -0,0 +1,39 @@
module Account::EntriesHelper
def permitted_entryable_partial_path(entry, relative_partial_path)
"account/entries/entryables/#{permitted_entryable_key(entry)}/#{relative_partial_path}"
end
def unconfirmed_transfer?(entry)
entry.marked_as_transfer? && entry.transfer.nil?
end
def transfer_entries(entries)
transfers = entries.select { |e| e.transfer_id.present? }
transfers.map(&:transfer).uniq
end
def entry_icon(entry, is_oldest: false)
if is_oldest
"keyboard"
elsif entry.trend.direction.up?
"arrow-up"
elsif entry.trend.direction.down?
"arrow-down"
else
"minus"
end
end
def entry_style(entry, is_oldest: false)
color = is_oldest ? "#D444F1" : entry.trend.color
mixed_hex_styles(color)
end
private
def permitted_entryable_key(entry)
permitted_entryable_paths = %w[transaction valuation]
entry.entryable_name_short.presence_in(permitted_entryable_paths)
end
end

View File

@@ -1,24 +0,0 @@
module Account::TransactionsHelper
def unconfirmed_transfer?(transaction)
transaction.marked_as_transfer && transaction.transfer.nil?
end
def group_transactions_by_date(transactions)
grouped_by_date = {}
transactions.each do |transaction|
if transaction.transfer
transfer_date = transaction.transfer.inflow_transaction.date
grouped_by_date[transfer_date] ||= { transactions: [], transfers: [] }
unless grouped_by_date[transfer_date][:transfers].include?(transaction.transfer)
grouped_by_date[transfer_date][:transfers] << transaction.transfer
end
else
grouped_by_date[transaction.date] ||= { transactions: [], transfers: [] }
grouped_by_date[transaction.date][:transactions] << transaction
end
end
grouped_by_date
end
end

View File

@@ -1,23 +0,0 @@
module Account::ValuationsHelper
def valuation_icon(valuation)
if valuation.oldest?
"keyboard"
elsif valuation.trend.direction.up?
"arrow-up"
elsif valuation.trend.direction.down?
"arrow-down"
else
"minus"
end
end
def valuation_style(valuation)
color = valuation.oldest? ? "#D444F1" : valuation.trend.color
<<-STYLE.strip
background-color: color-mix(in srgb, #{color} 5%, white);
border-color: color-mix(in srgb, #{color} 10%, white);
color: #{color};
STYLE
end
end

View File

@@ -65,6 +65,20 @@ module ApplicationHelper
end
end
def mixed_hex_styles(hex)
color = hex || "#1570EF" # blue-600
<<-STYLE.strip
background-color: color-mix(in srgb, #{color} 5%, white);
border-color: color-mix(in srgb, #{color} 10%, white);
color: #{color};
STYLE
end
def circle_logo(name, hex: nil, size: "md")
render partial: "shared/circle_logo", locals: { name: name, hex: hex, size: size }
end
def return_to_path(params, fallback = root_path)
uri = URI.parse(params[:return_to] || fallback)
uri.relative? ? uri.path : root_path
@@ -123,7 +137,7 @@ module ApplicationHelper
ActiveSupport::NumberHelper.number_to_delimited(money.amount.round(options[:precision] || 0), { delimiter: options[:delimiter], separator: options[:separator] })
end
def totals_by_currency(collection:, money_method:, separator: " | ", negate: false, options: {})
def totals_by_currency(collection:, money_method:, separator: " | ", negate: false)
collection.group_by(&:currency)
.transform_values { |item| negate ? item.sum(&money_method) * -1 : item.sum(&money_method) }
.map { |_currency, money| format_money(money) }

View File

@@ -11,6 +11,22 @@ module FormsHelper
end
end
def selectable_categories
Current.family.categories.alphabetically
end
def selectable_merchants
Current.family.merchants.alphabetically
end
def selectable_accounts
Current.family.accounts.alphabetically
end
def selectable_tags
Current.family.tags.alphabetically.pluck(:name, :id)
end
private
def radio_tab_contents(label:, icon:)
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm") do

View File

@@ -1,4 +1,4 @@
module Account::Transaction::SearchesHelper
module TransactionsHelper
def transaction_search_filters
[
{ key: "account_filter", name: "Account", icon: "layers" },

View File

@@ -25,7 +25,7 @@ export default class extends Controller {
submitBulkRequest(e) {
const form = e.target.closest("form");
const scope = e.params.scope
this.#addHiddenFormInputsForSelectedIds(form, `${scope}[transaction_ids][]`, this.selectedIdsValue)
this.#addHiddenFormInputsForSelectedIds(form, `${scope}[entry_ids][]`, this.selectedIdsValue)
form.requestSubmit()
}

View File

@@ -8,14 +8,17 @@ class Account < ApplicationRecord
belongs_to :family
belongs_to :institution, optional: true
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
has_many :balances, dependent: :destroy
has_many :valuations, dependent: :destroy
has_many :transactions, dependent: :destroy
has_many :imports, dependent: :destroy
monetize :balance
enum :status, { ok: "ok", syncing: "syncing", error: "error" }, validate: true
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
scope :active, -> { where(is_active: true) }
scope :assets, -> { where(classification: "asset") }
@@ -35,8 +38,7 @@ class Account < ApplicationRecord
# e.g. Wise, Revolut accounts that have transactions in multiple currencies
def multi_currency?
currencies = [ valuations.pluck(:currency), transactions.pluck(:currency) ].flatten.uniq
currencies.count > 1
entries.select(:currency).distinct.count > 1
end
# e.g. Accounts denominated in currency other than family currency
@@ -44,16 +46,6 @@ class Account < ApplicationRecord
currency != family.currency
end
def self.by_provider
# TODO: When 3rd party providers are supported, dynamically load all providers and their accounts
[ { name: "Manual accounts", accounts: all.order(balance: :desc).group_by(&:accountable_type) } ]
end
def self.some_syncing?
exists?(status: "syncing")
end
def series(period: Period.all, currency: self.currency)
balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code)
@@ -93,11 +85,19 @@ class Account < ApplicationRecord
account.accountable = Accountable.from_type(attributes[:accountable_type])&.new
# Always build the initial valuation
account.valuations.build(date: Date.current, value: attributes[:balance], currency: account.currency)
account.entries.build \
date: Date.current,
amount: attributes[:balance],
currency: account.currency,
entryable: Account::Valuation.new
# Conditionally build the optional start valuation
if start_date.present? && start_balance.present?
account.valuations.build(date: start_date, value: start_balance, currency: account.currency)
account.entries.build \
date: start_date,
amount: start_balance,
currency: account.currency,
entryable: Account::Valuation.new
end
account.save!

View File

@@ -1,123 +1,115 @@
class Account::Balance::Calculator
attr_reader :daily_balances, :errors, :warnings
attr_reader :errors, :warnings
def initialize(account, options = {})
@daily_balances = []
@errors = []
@warnings = []
@account = account
@calc_start_date = [ options[:calc_start_date], @account.effective_start_date ].compact.max
def initialize(account, options = {})
@errors = []
@warnings = []
@account = account
@calc_start_date = calculate_sync_start(options[:calc_start_date])
end
def daily_balances
@daily_balances ||= calculate_daily_balances
end
private
attr_reader :calc_start_date, :account
def calculate_sync_start(provided_start_date = nil)
if account.balances.any?
[ provided_start_date, account.effective_start_date ].compact.max
else
account.effective_start_date
end
end
def calculate
prior_balance = implied_start_balance
def calculate_daily_balances
prior_balance = nil
calculated_balances = ((@calc_start_date + 1.day)..Date.current).map do |date|
valuation = normalized_valuations.find { |v| v["date"] == date }
calculated_balances = (calc_start_date..Date.current).map do |date|
valuation_entry = find_valuation_entry(date)
if valuation
current_balance = valuation["value"]
if valuation_entry
current_balance = valuation_entry.amount
elsif prior_balance.nil?
current_balance = implied_start_balance
else
txn_flows = transaction_flows(date)
txn_entries = syncable_transaction_entries.select { |e| e.date == date }
txn_flows = transaction_flows(txn_entries)
current_balance = prior_balance - txn_flows
end
prior_balance = current_balance
{ date:, balance: current_balance, currency: @account.currency, updated_at: Time.current }
{ date:, balance: current_balance, currency: account.currency, updated_at: Time.current }
end
@daily_balances = [
{ date: @calc_start_date, balance: implied_start_balance, currency: @account.currency, updated_at: Time.current },
*calculated_balances
]
if @account.foreign_currency?
converted_balances = convert_balances_to_family_currency
@daily_balances.concat(converted_balances)
if account.foreign_currency?
calculated_balances.concat(convert_balances_to_family_currency(calculated_balances))
end
self
calculated_balances
end
private
def convert_balances_to_family_currency
rates = ExchangeRate.get_rates(
@account.currency,
@account.family.currency,
@calc_start_date..Date.current
).to_a
def syncable_entries
@entries ||= account.entries.where("date >= ?", calc_start_date).to_a
end
# Abort conversion if some required rates are missing
if rates.length != @daily_balances.length
@errors << :sync_message_missing_rates
return []
end
def syncable_transaction_entries
@syncable_transaction_entries ||= syncable_entries.select { |e| e.account_transaction? }
end
@daily_balances.map.with_index do |balance, index|
converted_balance = balance[:balance] * rates[index].rate
{ date: balance[:date], balance: converted_balance, currency: @account.family.currency, updated_at: Time.current }
end
def find_valuation_entry(date)
syncable_entries.find { |entry| entry.date == date && entry.account_valuation? }
end
def transaction_flows(transaction_entries)
converted_entries = transaction_entries.map { |entry| convert_entry_to_account_currency(entry) }.compact
flows = converted_entries.sum(&:amount)
flows *= -1 if account.liability?
flows
end
def convert_balances_to_family_currency(balances)
rates = ExchangeRate.get_rates(
account.currency,
account.family.currency,
calc_start_date..Date.current
).to_a
# Abort conversion if some required rates are missing
if rates.length != balances.length
@errors << :sync_message_missing_rates
return []
end
# For calculation, all transactions and valuations need to be normalized to the same currency (the account's primary currency)
def normalize_entries_to_account_currency(entries, value_key)
grouped_entries = entries.group_by(&:currency)
normalized_entries = []
balances.map.with_index do |balance, index|
converted_balance = balance[:balance] * rates[index].rate
{ date: balance[:date], balance: converted_balance, currency: account.family.currency, updated_at: Time.current }
end
end
grouped_entries.each do |currency, entries|
if currency != @account.currency
dates = entries.map(&:date).uniq
rates = ExchangeRate.get_rates(currency, @account.currency, dates).to_a
if rates.length != dates.length
@errors << :sync_message_missing_rates
else
entries.each do |entry|
## There can be several entries on the same date so we cannot rely on indeces
rate = rates.find { |rate| rate.date == entry.date }
value = entry.send(value_key)
value *= rate.rate
normalized_entries << entry.attributes.merge(value_key.to_s => value, "currency" => currency)
end
end
else
normalized_entries.concat(entries)
end
end
# Multi-currency accounts have transactions in many currencies
def convert_entry_to_account_currency(entry)
return entry if entry.currency == account.currency
normalized_entries
converted_entry = entry.dup
rate = ExchangeRate.find_rate(from: entry.currency, to: account.currency, date: entry.date)
unless rate
@errors << :sync_message_missing_rates
return nil
end
def normalized_valuations
@normalized_valuations ||= normalize_entries_to_account_currency(@account.valuations.where("date >= ?", @calc_start_date).order(:date).select(:date, :value, :currency), :value)
end
converted_entry.currency = account.currency
converted_entry.amount = entry.amount * rate.rate
converted_entry
end
def normalized_transactions
@normalized_transactions ||= normalize_entries_to_account_currency(@account.transactions.where("date >= ?", @calc_start_date).order(:date).select(:date, :amount, :currency), :amount)
end
def transaction_flows(date)
flows = normalized_transactions.select { |t| t["date"] == date }.sum { |t| t["amount"] }
flows *= -1 if @account.classification == "liability"
flows
end
def implied_start_balance
if @calc_start_date > @account.effective_start_date
return @account.balance_on(@calc_start_date)
end
oldest_valuation_date = normalized_valuations.first&.date
oldest_transaction_date = normalized_transactions.first&.date
oldest_entry_date = [ oldest_valuation_date, oldest_transaction_date ].compact.min
if oldest_entry_date.present? && oldest_entry_date == oldest_valuation_date
oldest_valuation = normalized_valuations.find { |v| v["date"] == oldest_valuation_date }
oldest_valuation["value"].to_d
else
net_transaction_flows = normalized_transactions.sum { |t| t["amount"].to_d }
net_transaction_flows *= -1 if @account.classification == "liability"
@account.balance.to_d + net_transaction_flows
end
end
def implied_start_balance
transaction_entries = syncable_transaction_entries.select { |e| e.date > calc_start_date }
account.balance.to_d + transaction_flows(transaction_entries)
end
end

196
app/models/account/entry.rb Normal file
View File

@@ -0,0 +1,196 @@
class Account::Entry < ApplicationRecord
include Monetizable
monetize :amount
belongs_to :account
belongs_to :transfer, optional: true
delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy
accepts_nested_attributes_for :entryable
validates :date, :amount, :currency, presence: true
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
scope :chronological, -> { order(:date, :created_at) }
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
scope :without_transfers, -> { where(marked_as_transfer: false) }
scope :with_converted_amount, ->(currency) {
# Join with exchange rates to convert the amount to the given currency
# If no rate is available, exclude the transaction from the results
select(
"account_entries.*",
"account_entries.amount * COALESCE(er.rate, 1) AS converted_amount"
)
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.base_currency AND er.converted_currency = ?", currency ]))
.where("er.rate IS NOT NULL OR account_entries.currency = ?", currency)
}
def sync_account_later
if destroyed?
sync_start_date = previous_entry&.date
else
sync_start_date = [ date_previously_was, date ].compact.min
end
account.sync_later(sync_start_date)
end
def inflow?
amount <= 0 && account_transaction?
end
def outflow?
amount > 0 && account_transaction?
end
def first_of_type?
first_entry = account
.entries
.where("entryable_type = ?", entryable_type)
.order(:date)
.first
first_entry&.id == id
end
def entryable_name_short
entryable_type.demodulize.underscore
end
def trend
@trend ||= create_trend
end
class << self
def daily_totals(entries, currency, period: Period.last_30_days)
# Sum spending and income for each day in the period with the given currency
select(
"gs.date",
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
)
.from(entries.with_converted_amount(currency), :e)
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON e.date = gs.date", period.date_range.first, period.date_range.last ]))
.group("gs.date")
end
def daily_rolling_totals(entries, currency, period: Period.last_30_days)
# Extend the period to include the rolling window
period_with_rolling = period.extend_backward(period.date_range.count.days)
# Aggregate the rolling sum of spending and income based on daily totals
rolling_totals = from(daily_totals(entries, currency, period: period_with_rolling))
.select(
"*",
sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
)
.order(:date)
# Trim the results to the original period
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
end
def mark_transfers!
update_all marked_as_transfer: true
# Attempt to "auto match" and save a transfer if 2 transactions selected
Account::Transfer.new(entries: all).save if all.count == 2
end
def bulk_update!(bulk_update_params)
bulk_attributes = {
date: bulk_update_params[:date],
entryable_attributes: {
notes: bulk_update_params[:notes],
category_id: bulk_update_params[:category_id],
merchant_id: bulk_update_params[:merchant_id]
}.compact_blank
}.compact_blank
return 0 if bulk_attributes.blank?
transaction do
all.each do |entry|
bulk_attributes[:entryable_attributes][:id] = entry.entryable_id if bulk_attributes[:entryable_attributes].present?
entry.update! bulk_attributes
end
end
all.size
end
def income_total(currency = "USD")
account_transactions.includes(:entryable)
.where("account_entries.amount <= 0")
.where("account_entries.currency = ?", currency)
.reject { |e| e.marked_as_transfer? }
.sum(&:amount_money)
end
def expense_total(currency = "USD")
account_transactions.includes(:entryable)
.where("account_entries.amount > 0")
.where("account_entries.currency = ?", currency)
.reject { |e| e.marked_as_transfer? }
.sum(&:amount_money)
end
def search(params)
query = all
query = query.where("account_entries.name ILIKE ?", "%#{params[:search]}%") if params[:search].present?
query = query.where("account_entries.date >= ?", params[:start_date]) if params[:start_date].present?
query = query.where("account_entries.date <= ?", params[:end_date]) if params[:end_date].present?
if params[:accounts].present? || params[:account_ids].present?
query = query.joins(:account)
end
query = query.where(accounts: { name: params[:accounts] }) if params[:accounts].present?
query = query.where(accounts: { id: params[:account_ids] }) if params[:account_ids].present?
# Search attributes on each entryable to further refine results
entryable_ids = entryable_search(params)
query = query.where(entryable_id: entryable_ids) unless entryable_ids.nil?
query
end
private
def entryable_search(params)
entryable_ids = []
entryable_search_performed = false
Account::Entryable::TYPES.map(&:constantize).each do |entryable|
next unless entryable.requires_search?(params)
entryable_search_performed = true
entryable_ids += entryable.search(params).pluck(:id)
end
return nil unless entryable_search_performed
entryable_ids
end
end
private
def previous_entry
@previous_entry ||= account
.entries
.where("date < ?", date)
.where("entryable_type = ?", entryable_type)
.order(date: :desc)
.first
end
def create_trend
TimeSeries::Trend.new \
current: amount_money,
previous: previous_entry&.amount_money,
favorable_direction: account.favorable_direction
end
end

View File

@@ -0,0 +1,13 @@
module Account::Entryable
extend ActiveSupport::Concern
TYPES = %w[ Account::Valuation Account::Transaction ]
def self.from_type(entryable_type)
entryable_type.presence_in(TYPES).constantize
end
included do
has_one :entry, as: :entryable, touch: true
end
end

View File

@@ -8,17 +8,22 @@ module Account::Syncable
def sync(start_date = nil)
update!(status: "syncing")
sync_exchange_rates
if multi_currency? || foreign_currency?
sync_exchange_rates
end
calc_start_date = start_date - 1.day if start_date.present? && self.balance_on(start_date - 1.day).present?
calculator = Account::Balance::Calculator.new(self, { calc_start_date: start_date })
calculator = Account::Balance::Calculator.new(self, { calc_start_date: })
calculator.calculate
self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique)
self.balances.where("date < ?", effective_start_date).delete_all
new_balance = calculator.daily_balances.select { |b| b[:currency] == self.currency }.last[:balance]
update!(status: "ok", last_sync_date: Date.today, balance: new_balance, sync_errors: calculator.errors, sync_warnings: calculator.warnings)
update! \
status: "ok",
last_sync_date: Date.current,
balance: new_balance,
sync_errors: calculator.errors,
sync_warnings: calculator.warnings
rescue => e
update!(status: "error", sync_errors: [ :sync_message_unknown_error ])
logger.error("Failed to sync account #{id}: #{e.message}")
@@ -37,10 +42,7 @@ module Account::Syncable
# The earliest date we can calculate a balance for
def effective_start_date
first_valuation_date = self.valuations.order(:date).pluck(:date).first
first_transaction_date = self.transactions.order(:date).pluck(:date).first
[ first_valuation_date, first_transaction_date&.prev_day ].compact.min || Date.current
@effective_start_date ||= entries.order(:date).first.try(:date) || Date.current
end
# Finds all the rate pairs that are required to calculate balances for an account and syncs them
@@ -48,7 +50,7 @@ module Account::Syncable
rate_candidates = []
if multi_currency?
transactions_in_foreign_currency = self.transactions.where.not(currency: self.currency).pluck(:currency, :date).uniq
transactions_in_foreign_currency = self.entries.where.not(currency: self.currency).pluck(:currency, :date).uniq
transactions_in_foreign_currency.each do |currency, date|
rate_candidates << { date: date, from_currency: currency, to_currency: self.currency }
end
@@ -60,6 +62,8 @@ module Account::Syncable
end
end
return if rate_candidates.blank?
existing_rates = ExchangeRate.where(
base_currency: rate_candidates.map { |rc| rc[:from_currency] },
converted_currency: rate_candidates.map { |rc| rc[:to_currency] },

View File

@@ -1,118 +1,32 @@
class Account::Transaction < ApplicationRecord
include Monetizable
include Account::Entryable
monetize :amount
belongs_to :account
belongs_to :transfer, optional: true, class_name: "Account::Transfer"
belongs_to :category, optional: true
belongs_to :merchant, optional: true
has_many :taggings, as: :taggable, dependent: :destroy
has_many :tags, through: :taggings
accepts_nested_attributes_for :taggings, allow_destroy: true
validates :name, :date, :amount, :account, presence: true
scope :ordered, -> { order(date: :desc) }
scope :active, -> { where(excluded: false) }
scope :inflows, -> { where("amount <= 0") }
scope :outflows, -> { where("amount > 0") }
scope :by_name, ->(name) { where("account_transactions.name ILIKE ?", "%#{name}%") }
scope :with_categories, ->(categories) { joins(:category).where(categories: { name: categories }) }
scope :with_accounts, ->(accounts) { joins(:account).where(accounts: { name: accounts }) }
scope :with_account_ids, ->(account_ids) { joins(:account).where(accounts: { id: account_ids }) }
scope :with_merchants, ->(merchants) { joins(:merchant).where(merchants: { name: merchants }) }
scope :on_or_after_date, ->(date) { where("account_transactions.date >= ?", date) }
scope :on_or_before_date, ->(date) { where("account_transactions.date <= ?", date) }
scope :with_converted_amount, ->(currency = Current.family.currency) {
# Join with exchange rates to convert the amount to the given currency
# If no rate is available, exclude the transaction from the results
select(
"account_transactions.*",
"account_transactions.amount * COALESCE(er.rate, 1) AS converted_amount"
)
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_transactions.date = er.date AND account_transactions.currency = er.base_currency AND er.converted_currency = ?", currency ]))
.where("er.rate IS NOT NULL OR account_transactions.currency = ?", currency)
}
def inflow?
amount <= 0
end
def outflow?
amount > 0
end
def transfer?
marked_as_transfer
end
def sync_account_later
if destroyed?
sync_start_date = previous_transaction_date
else
sync_start_date = [ date_previously_was, date ].compact.min
end
account.sync_later(sync_start_date)
end
class << self
def income_total(currency = "USD")
inflows.reject(&:transfer?).select { |t| t.currency == currency }.sum(&:amount_money)
end
def expense_total(currency = "USD")
outflows.reject(&:transfer?).select { |t| t.currency == currency }.sum(&:amount_money)
end
def mark_transfers!
update_all marked_as_transfer: true
# Attempt to "auto match" and save a transfer if 2 transactions selected
Account::Transfer.new(transactions: all).save if all.count == 2
end
def daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
# Sum spending and income for each day in the period with the given currency
select(
"gs.date",
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
)
.from(transactions.with_converted_amount(currency).where(marked_as_transfer: false), :t)
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ]))
.group("gs.date")
end
def daily_rolling_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
# Extend the period to include the rolling window
period_with_rolling = period.extend_backward(period.date_range.count.days)
# Aggregate the rolling sum of spending and income based on daily totals
rolling_totals = from(daily_totals(transactions, period: period_with_rolling, currency: currency))
.select(
"*",
sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
)
.order("date")
# Trim the results to the original period
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
end
def search(params)
query = all.includes(:transfer)
query = query.by_name(params[:search]) if params[:search].present?
query = query.with_categories(params[:categories]) if params[:categories].present?
query = query.with_accounts(params[:accounts]) if params[:accounts].present?
query = query.with_account_ids(params[:account_ids]) if params[:account_ids].present?
query = query.with_merchants(params[:merchants]) if params[:merchants].present?
query = query.on_or_after_date(params[:start_date]) if params[:start_date].present?
query = query.on_or_before_date(params[:end_date]) if params[:end_date].present?
query = all
query = query.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id").where(categories: { name: params[:categories] }) if params[:categories].present?
query = query.joins("LEFT JOIN merchants ON merchants.id = account_transactions.merchant_id").where(merchants: { name: params[:merchants] }) if params[:merchants].present?
query
end
def requires_search?(params)
searchable_keys.any? { |key| params.key?(key) }
end
private
def searchable_keys
%i[ categories merchants ]
end
end
private

View File

@@ -1,21 +1,42 @@
class Account::Transfer < ApplicationRecord
has_many :transactions, dependent: :nullify
has_many :entries, dependent: :nullify
validate :net_zero_flows, if: :single_currency_transfer?
validate :transaction_count, :from_different_accounts, :all_transactions_marked
def date
outflow_transaction&.date
end
def amount_money
entries.first&.amount_money&.abs
end
def from_name
outflow_transaction&.account&.name
end
def to_name
inflow_transaction&.account&.name
end
def name
return nil unless from_name && to_name
I18n.t("account.transfer.name", from_account: from_name, to_account: to_name)
end
def inflow_transaction
transactions.find { |t| t.inflow? }
entries.find { |e| e.inflow? }
end
def outflow_transaction
transactions.find { |t| t.outflow? }
entries.find { |e| e.outflow? }
end
def destroy_and_remove_marks!
transaction do
transactions.each do |t|
t.update! marked_as_transfer: false
entries.each do |e|
e.update! marked_as_transfer: false
end
destroy!
@@ -24,39 +45,52 @@ class Account::Transfer < ApplicationRecord
class << self
def build_from_accounts(from_account, to_account, date:, amount:, currency:, name:)
outflow = from_account.transactions.build(amount: amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true)
inflow = to_account.transactions.build(amount: -amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true)
outflow = from_account.entries.build \
amount: amount.abs,
currency: currency,
date: date,
name: name,
marked_as_transfer: true,
entryable: Account::Transaction.new
new transactions: [ outflow, inflow ]
inflow = to_account.entries.build \
amount: amount.abs * -1,
currency: currency,
date: date,
name: name,
marked_as_transfer: true,
entryable: Account::Transaction.new
new entries: [ outflow, inflow ]
end
end
private
def single_currency_transfer?
transactions.map(&:currency).uniq.size == 1
entries.map { |e| e.currency }.uniq.size == 1
end
def transaction_count
unless transactions.size == 2
errors.add :transactions, "must have exactly 2 transactions"
unless entries.size == 2
errors.add :entries, "must have exactly 2 entries"
end
end
def from_different_accounts
accounts = transactions.map(&:account_id).uniq
errors.add :transactions, "must be from different accounts" if accounts.size < transactions.size
accounts = entries.map { |e| e.account_id }.uniq
errors.add :entries, "must be from different accounts" if accounts.size < entries.size
end
def net_zero_flows
unless transactions.sum(&:amount).zero?
unless entries.sum(&:amount).zero?
errors.add :transactions, "must have an inflow and outflow that net to zero"
end
end
def all_transactions_marked
unless transactions.all?(&:marked_as_transfer)
errors.add :transactions, "must be marked as transfer"
unless entries.all?(&:marked_as_transfer)
errors.add :entries, "must be marked as transfer"
end
end
end

View File

@@ -1,48 +1,13 @@
class Account::Valuation < ApplicationRecord
include Monetizable
include Account::Entryable
monetize :value
belongs_to :account
validates :account, :date, :value, presence: true
validates :date, uniqueness: { scope: :account_id }
scope :chronological, -> { order(:date) }
scope :reverse_chronological, -> { order(date: :desc) }
def trend
@trend ||= create_trend
end
def oldest?
account.valuations.chronological.limit(1).pluck(:date).first == self.date
end
def sync_account_later
if destroyed?
sync_start_date = previous_valuation&.date
else
sync_start_date = [ date_previously_was, date ].compact.min
class << self
def search(_params)
all
end
account.sync_later(sync_start_date)
def requires_search?(_params)
false
end
end
private
def previous_valuation
@previous_valuation ||= self.account
.valuations
.where("date < ?", date)
.order(date: :desc)
.first
end
def create_trend
TimeSeries::Trend.new \
current: self.value,
previous: previous_valuation&.value,
favorable_direction: account.favorable_direction
end
end

View File

@@ -6,7 +6,7 @@ module Monetizable
fields.each do |field|
define_method("#{field}_money") do
value = self.send(field)
value.nil? ? nil : Money.new(value, currency)
value.nil? ? nil : Money.new(value, currency || Money.default_currency)
end
end
end

View File

@@ -3,7 +3,8 @@ class Family < ApplicationRecord
has_many :tags, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :institutions, dependent: :destroy
has_many :transactions, through: :accounts, class_name: "Account::Transaction"
has_many :transactions, through: :accounts
has_many :entries, through: :accounts
has_many :imports, through: :accounts
has_many :categories, dependent: :destroy
has_many :merchants, dependent: :destroy
@@ -34,17 +35,18 @@ class Family < ApplicationRecord
def snapshot_account_transactions
period = Period.last_30_days
results = accounts.active.joins(:transactions)
.select(
"accounts.*",
"COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS spending",
"COALESCE(SUM(-amount) FILTER (WHERE amount < 0), 0) AS income"
)
.where("account_transactions.date >= ?", period.date_range.begin)
.where("account_transactions.date <= ?", period.date_range.end)
.where("account_transactions.marked_as_transfer = ?", false)
.group("id")
.to_a
results = accounts.active.joins(:entries)
.select(
"accounts.*",
"COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending",
"COALESCE(SUM(-account_entries.amount) FILTER (WHERE account_entries.amount < 0), 0) AS income"
)
.where("account_entries.date >= ?", period.date_range.begin)
.where("account_entries.date <= ?", period.date_range.end)
.where("account_entries.marked_as_transfer = ?", false)
.where("account_entries.entryable_type = ?", "Account::Transaction")
.group("id")
.to_a
results.each do |r|
r.define_singleton_method(:savings_rate) do
@@ -60,7 +62,8 @@ class Family < ApplicationRecord
end
def snapshot_transactions
rolling_totals = Account::Transaction.daily_rolling_totals(transactions, period: Period.last_30_days, currency: self.currency)
candidate_entries = entries.account_transactions.without_transfers
rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days)
spending = []
income = []
@@ -89,10 +92,6 @@ class Family < ApplicationRecord
}
end
def effective_start_date
accounts.active.joins(:balances).minimum("account_balances.date") || Date.current
end
def net_worth
assets - liabilities
end

View File

@@ -111,7 +111,7 @@ class Import < ApplicationRecord
end
def generate_transactions
transactions = []
transaction_entries = []
category_cache = {}
tag_cache = {}
@@ -126,18 +126,17 @@ class Import < ApplicationRecord
category = category_cache[category_name] ||= account.family.categories.find_or_initialize_by(name: category_name) if category_name.present?
txn = account.transactions.build \
entry = account.entries.build \
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
date: Date.iso8601(row["date"]),
category: category,
tags: tags,
amount: BigDecimal(row["amount"]) * -1, # User inputs amounts with opposite signage of our internal representation
currency: account.currency
currency: account.currency,
amount: BigDecimal(row["amount"]) * -1,
entryable: Account::Transaction.new(category: category, tags: tags)
transactions << txn
transaction_entries << entry
end
transactions
transaction_entries
end
def create_expected_fields

View File

@@ -0,0 +1,4 @@
<%# locals: (entry:, **opts) %>
<%= turbo_frame_tag dom_id(entry) do %>
<%= render permitted_entryable_partial_path(entry, entry.entryable_name_short), entry: entry, **opts %>
<% end %>

View File

@@ -0,0 +1,21 @@
<%# locals: (date:, entries:, selectable: true, **opts) %>
<div id="entry-group-<%= date %>" class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
<div class="flex pl-0.5 items-center gap-4">
<% if selectable %>
<%= check_box_tag "#{date}_entries_selection",
class: ["maybe-checkbox maybe-checkbox--light", "hidden": entries.size == 0],
id: "selection_entry_#{date}",
data: { action: "bulk-select#toggleGroupSelection" } %>
<% end %>
<%= tag.span "#{date.strftime('%b %d, %Y')} · #{entries.size}" %>
</div>
<%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %>
</div>
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
<%= render entries.reject { |e| e.transfer_id.present? }, selectable:, **opts %>
<%= render transfer_entries(entries), selectable:, **opts %>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<div class="fixed bottom-6 z-10 flex items-center justify-between rounded-xl bg-gray-900 px-4 text-sm text-white w-[420px] py-1.5">
<div class="flex items-center gap-2">
<%= check_box_tag "transaction_selection", 1, true, class: "maybe-checkbox maybe-checkbox--dark", data: { action: "bulk-select#deselectAll" } %>
<%= check_box_tag "entry_selection", 1, true, class: "maybe-checkbox maybe-checkbox--dark", data: { action: "bulk-select#deselectAll" } %>
<p data-bulk-select-target="selectionBarText"></p>
</div>
@@ -19,7 +19,12 @@
accept: t(".mark_transfers_confirm"),
}
} do |f| %>
<button id="bulk-transfer-btn" type="button" data-bulk-select-scope-param="bulk_update" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Mark as transfer">
<button id="bulk-transfer-btn"
type="button"
data-bulk-select-scope-param="bulk_update"
data-action="bulk-select#submitBulkRequest"
class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md"
title="Mark as transfer">
<%= lucide_icon "arrow-right-left", class: "w-5 group-hover:text-white" %>
</button>
<% end %>

View File

@@ -0,0 +1,3 @@
<%= turbo_frame_tag dom_id(@entry) do %>
<%= render permitted_entryable_partial_path(@entry, "edit"), entry: @entry %>
<% end %>

View File

@@ -0,0 +1,123 @@
<%# locals: (entry:) %>
<% transaction, account = entry.account_transaction, entry.account %>
<%= drawer do %>
<div>
<header class="mb-4 space-y-1">
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl"><%= format_money -entry.amount_money %></span>
<span class="text-lg text-gray-500"><%= entry.currency %></span>
</h3>
<% if entry.marked_as_transfer? %>
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
<% end %>
</div>
<span class="text-sm text-gray-500"><%= entry.date.strftime("%A %d %B") %></span>
</header>
<div class="space-y-2">
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".overview") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-6">
<%= form_with model: [account, entry], url: account_entry_path(account, entry), html: { data: { controller: "auto-submit-form" } } do |f| %>
<div class="space-y-2">
<%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %>
<% unless entry.marked_as_transfer? %>
<div class="flex space-x-2">
<div>
<%= f.select :nature, [["Expense", "expense"], ["Income", "income"]], { label: t(".nature"), selected: entry.amount.negative? ? "income" : "expense" }, "data-auto-submit-form-target": "auto" %>
</div>
<div class="flex-grow">
<%= f.number_field :amount, value: entry.amount.abs, label: t(".amount"), step: "0.01", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "change" %>
</div>
</div>
<% end %>
<%= f.date_field :date, label: t(".date_label"), max: Date.current, "data-auto-submit-form-target": "auto" %>
<%= f.fields_for :entryable do |ef| %>
<% unless entry.marked_as_transfer? %>
<%= ef.collection_select :category_id, selectable_categories, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
<%= ef.collection_select :merchant_id, selectable_merchants, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
<%= f.collection_select :account_id, selectable_accounts, :id, :name, { prompt: t(".account_placeholder"), label: t(".account_label"), class: "text-gray-500" }, { class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled" } %>
</div>
<% end %>
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".additional") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-6 space-y-2">
<%= form_with model: [account, entry], url: account_entry_path(account, entry), html: { data: { controller: "auto-submit-form" } } do |f| %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.select :tag_ids,
options_for_select(selectable_tags, transaction.tag_ids),
{
multiple: true,
label: t(".tags_label"),
class: "placeholder:text-gray-500"
},
"data-auto-submit-form-target": "auto" %>
<%= ef.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), "data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".settings") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-6">
<%= form_with model: [account, entry], url: account_entry_path(account, entry), html: { class: "p-3 space-y-3", data: { controller: "auto-submit-form" } } do |f| %>
<%= f.fields_for :entryable do |ef| %>
<div class="flex cursor-pointer items-center gap-2 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
</div>
<div class="relative inline-block select-none">
<%= ef.check_box :excluded, class: "sr-only peer", "data-auto-submit-form-target": "auto" %>
<label for="account_entry_entryable_attributes_excluded" class="maybe-switch"></label>
</div>
</div>
<% end %>
<% end %>
<% unless entry.marked_as_transfer? %>
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".delete_title") %></h4>
<p class="text-gray-500"><%= t(".delete_subtitle") %></p>
</div>
<%= button_to t(".delete"),
account_entry_path(account, entry),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
data: { turbo_confirm: true, turbo_frame: "_top" } %>
</div>
<% end %>
</div>
</details>
</div>
</div>
<% end %>

View File

@@ -1,26 +1,27 @@
<%# locals: (transaction:, selectable: true, editable: true, short: false, show_tags: false) %>
<%= turbo_frame_tag dom_id(transaction), class: "grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4" do %>
<%# locals: (entry:, selectable: true, editable: true, short: false, show_tags: false, **opts) %>
<% transaction, account = entry.account_transaction, entry.account %>
<% name_col_span = transaction.transfer? ? "col-span-10" : short ? "col-span-6" : "col-span-4" %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<% name_col_span = entry.marked_as_transfer? ? "col-span-10" : short ? "col-span-6" : "col-span-4" %>
<div class="pr-10 flex items-center gap-4 <%= name_col_span %>">
<% if selectable %>
<%= check_box_tag dom_id(transaction, "selection"),
<%= check_box_tag dom_id(entry, "selection"),
class: "maybe-checkbox maybe-checkbox--light",
data: { id: transaction.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
<% end %>
<div class="max-w-full">
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
<%= transaction.name[0].upcase %>
<%= entry.name[0].upcase %>
</div>
<div class="truncate text-gray-900">
<% if transaction.new_record? %>
<%= content_tag :p, transaction.name %>
<% if entry.new_record? %>
<%= content_tag :p, entry.name %>
<% else %>
<%= link_to transaction.name,
account_transaction_path(transaction.account, transaction),
<%= link_to entry.name,
account_entry_path(account, entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
<% end %>
@@ -28,7 +29,7 @@
<% end %>
</div>
<% if unconfirmed_transfer?(transaction) %>
<% if unconfirmed_transfer?(entry) %>
<% if editable %>
<%= form_with url: unmark_transfers_transactions_path, builder: ActionView::Helpers::FormBuilder, class: "flex items-center", data: {
turbo_confirm: {
@@ -38,7 +39,7 @@
},
turbo_frame: "_top"
} do |f| %>
<%= f.hidden_field "bulk_update[transaction_ids][]", value: transaction.id %>
<%= f.hidden_field "bulk_update[entry_ids][]", value: entry.id %>
<%= f.button class: "flex items-center justify-center group", title: "Remove transfer" do %>
<%= lucide_icon "arrow-left-right", class: "group-hover:hidden text-gray-500 w-4 h-4" %>
<%= lucide_icon "unlink", class: "hidden group-hover:inline-block text-gray-900 w-4 h-4" %>
@@ -50,7 +51,7 @@
<% end %>
</div>
<% unless transaction.transfer? %>
<% unless entry.marked_as_transfer? %>
<% unless short %>
<div class="flex items-center gap-1 <%= show_tags ? "col-span-6" : "col-span-3" %>">
<% if editable %>
@@ -69,11 +70,11 @@
<% unless show_tags %>
<%= tag.div class: short ? "col-span-4" : "col-span-3" do %>
<% if transaction.new_record? %>
<%= tag.p transaction.account.name %>
<% if entry.new_record? %>
<%= tag.p account.name %>
<% else %>
<%= link_to transaction.account.name,
account_path(transaction.account, tab: "transactions"),
<%= link_to account.name,
account_path(account, tab: "transactions"),
data: { turbo_frame: "_top" },
class: "hover:underline" %>
<% end %>
@@ -83,7 +84,7 @@
<div class="col-span-2 ml-auto">
<%= content_tag :p,
format_money(-transaction.amount_money),
class: ["text-green-600": transaction.inflow?] %>
format_money(-entry.amount_money),
class: ["text-green-600": entry.inflow?] %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1 @@
<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %>

View File

@@ -0,0 +1,24 @@
<%# locals: (entry:) %>
<%= form_with model: [entry.account, entry],
data: { turbo_frame: "_top" },
url: entry.new_record? ? account_entries_path(entry.account) : account_entry_path(entry.account, entry),
builder: ActionView::Helpers::FormBuilder do |f| %>
<div class="grid grid-cols-10 p-4 items-center">
<div class="col-span-7 flex items-center gap-4">
<div class="w-8 h-8 rounded-full p-1.5 flex items-center justify-center bg-gray-500/5">
<%= lucide_icon("pencil-line", class: "w-4 h-4 text-gray-500") %>
</div>
<div class="w-full flex items-center justify-between gap-2">
<%= f.date_field :date, required: "required", max: Date.current, class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5 text-gray-900 text-sm" %>
<%= f.number_field :amount, required: "required", placeholder: "0.00", step: "0.01", class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs text-gray-900 text-sm px-3 py-1.5 text-right" %>
<%= f.hidden_field :currency, value: entry.account.currency %>
<%= f.hidden_field :entryable_type, value: entry.entryable_type %>
</div>
</div>
<div class="col-span-3 flex gap-2 justify-end items-center">
<%= link_to t(".cancel"), valuation_account_entries_path(entry.account), class: "text-sm text-gray-900 hover:text-gray-800 font-medium px-3 py-1.5" %>
<%= f.submit class: "bg-gray-50 rounded-lg font-medium px-3 py-1.5 cursor-pointer hover:bg-gray-100 text-sm" %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,2 @@
<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %>
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>

View File

@@ -0,0 +1 @@
<%= render permitted_entryable_partial_path(@entry, "valuation"), entry: @entry %>

View File

@@ -1,36 +1,42 @@
<%= turbo_frame_tag dom_id(valuation) do %>
<%# locals: (entry:, **opts) %>
<% account = entry.account %>
<%= turbo_frame_tag dom_id(entry) do %>
<% is_oldest = entry.first_of_type? %>
<div class="p-4 grid grid-cols-10 items-center">
<div class="col-span-5 flex items-center gap-4">
<%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: valuation_style(valuation).html_safe do %>
<%= lucide_icon valuation_icon(valuation), class: "w-4 h-4" %>
<%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: entry_style(entry, is_oldest:).html_safe do %>
<%= lucide_icon entry_icon(entry, is_oldest:), class: "w-4 h-4" %>
<% end %>
<div class="text-sm">
<%= tag.p valuation.date, class: "text-gray-900 font-medium" %>
<%= tag.p valuation.oldest? ? t(".start_balance") : t(".value_update"), class: "text-gray-500" %>
<%= tag.p entry.date, class: "text-gray-900 font-medium" %>
<%= tag.p is_oldest ? t(".start_balance") : t(".value_update"), class: "text-gray-500" %>
</div>
</div>
<div class="col-span-2 justify-self-end">
<%= tag.p format_money(valuation.value_money), class: "font-medium text-sm text-gray-900" %>
<%= tag.p format_money(entry.amount_money), class: "font-medium text-sm text-gray-900" %>
</div>
<div class="col-span-2 justify-self-end font-medium text-sm" style="color: <%= valuation.trend.color %>">
<% if valuation.trend.direction.flat? %>
<div class="col-span-2 justify-self-end font-medium text-sm" style="color: <%= entry.trend.color %>">
<% if entry.trend.direction.flat? %>
<%= tag.span t(".no_change"), class: "text-gray-500" %>
<% else %>
<%= tag.span format_money(valuation.trend.value) %>
<%= tag.span "(#{valuation.trend.percent}%)" %>
<%= tag.span format_money(entry.trend.value) %>
<%= tag.span "(#{entry.trend.percent}%)" %>
<% end %>
</div>
<div class="col-span-1 justify-self-end">
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= contextual_menu_modal_action_item t(".edit_entry"), edit_account_valuation_path(valuation.account, valuation) %>
<%= contextual_menu_modal_action_item t(".edit_entry"), edit_account_entry_path(account, entry) %>
<%= contextual_menu_destructive_item t(".delete_entry"),
account_valuation_path(valuation.account, valuation),
account_entry_path(account, entry),
turbo_frame: "_top",
turbo_confirm: {
title: t(".confirm_title"),

View File

@@ -0,0 +1,3 @@
<%= turbo_frame_tag dom_id(@entry) do %>
<%= render permitted_entryable_partial_path(@entry, "new"), entry: @entry %>
<% end %>

View File

@@ -0,0 +1 @@
<%= render partial: permitted_entryable_partial_path(@entry, "show"), locals: { entry: @entry } %>

View File

@@ -12,15 +12,15 @@
<div id="transactions" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".transaction") %>">
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
<%= render "account/transactions/selection_bar" %>
<%= render "selection_bar" %>
</div>
<% if @transactions.empty? %>
<% if @transaction_entries.empty? %>
<p class="text-gray-500 py-4"><%= t(".no_transactions") %></p>
<% else %>
<div class="space-y-6">
<% group_transactions_by_date(@transactions).each do |date, group| %>
<%= render "transaction_group", date:, transactions: group[:transactions], transfers: group[:transfers] %>
<% @transaction_entries.group_by(&:date).each do |date, entries| %>
<%= render "entry_group", date:, entries: entries %>
<% end %>
</div>
<% end %>

View File

@@ -2,8 +2,8 @@
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex items-center justify-between">
<%= tag.h2 t(".valuations"), class: "font-medium text-lg" %>
<%= link_to new_account_valuation_path(@account),
data: { turbo_frame: dom_id(Account::Valuation.new) },
<%= link_to new_account_entry_path(@account, entryable_type: "Account::Valuation"),
data: { turbo_frame: dom_id(@account.entries.account_valuations.new) },
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
<%= tag.span t(".new_entry"), class: "text-sm" %>
@@ -19,13 +19,13 @@
</div>
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
<%= turbo_frame_tag dom_id(Account::Valuation.new) %>
<%= turbo_frame_tag dom_id(@account.entries.account_valuations.new) %>
<% valuations = @account.valuations.reverse_chronological %>
<% if valuations.any? %>
<%= render partial: "valuation",
collection: @account.valuations.reverse_chronological,
spacer_template: "valuation_ruler" %>
<% if @valuation_entries.any? %>
<%= render partial: "account/entries/entryables/valuation/valuation",
collection: @valuation_entries,
as: :entry,
spacer_template: "ruler" %>
<% else %>
<p class="text-gray-500 text-sm p-4"><%= t(".no_valuations") %></p>
<% end %>

View File

@@ -1 +0,0 @@
<%= render "account/transactions/transaction", transaction: @transaction %>

View File

@@ -1,21 +0,0 @@
<%# locals: (date:, transactions:, transfers: [], selectable: true, **transaction_opts) %>
<div id="date-group-<%= date %>" class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
<div class="flex pl-0.5 items-center gap-4">
<% if selectable %>
<%= check_box_tag "#{date}_transactions_selection",
class: ["maybe-checkbox maybe-checkbox--light", "hidden": transactions.count == 0],
id: "selection_transaction_#{date}",
data: { action: "bulk-select#toggleGroupSelection" } %>
<% end %>
<%= tag.span "#{date.strftime('%b %d, %Y')} · #{transactions.size + (transfers.size * 2)}" %>
</div>
<%= totals_by_currency(collection: transactions, money_method: :amount_money, negate: true) %>
</div>
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
<%= render transactions, selectable:, **transaction_opts.except(:selectable) %>
<%= render transfers %>
</div>
</div>

View File

@@ -1,103 +0,0 @@
<%= drawer do %>
<div>
<header class="mb-4 space-y-1">
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl"><%= format_money -@transaction.amount_money %></span>
<span class="text-lg text-gray-500"><%= @transaction.currency %></span>
</h3>
<% if @transaction.marked_as_transfer %>
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
<% end %>
</div>
<span class="text-sm text-gray-500"><%= @transaction.date.strftime("%A %d %B") %></span>
</header>
<div class="space-y-2">
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".overview") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-6">
<%= form_with model: [@account, @transaction], url: account_transaction_path, html: { data: { controller: "auto-submit-form" } } do |f| %>
<div class="space-y-2">
<%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %>
<%= f.date_field :date, label: t(".date_label"), max: Date.today, "data-auto-submit-form-target": "auto" %>
<% unless @transaction.marked_as_transfer %>
<%= f.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
<%= f.collection_select :merchant_id, Current.family.merchants.alphabetically, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
<% end %>
<%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_placeholder"), label: t(".account_label"), class: "text-gray-500" }, { class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled" } %>
</div>
<% end %>
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".additional") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-6 space-y-2">
<%= form_with model: [@account, @transaction], url: account_transaction_path, html: { data: { controller: "auto-submit-form" } } do |f| %>
<%= f.select :tag_ids,
options_for_select(Current.family.tags.alphabetically.pluck(:name, :id), @transaction.tag_ids),
{
multiple: true,
label: t(".tags_label"),
class: "placeholder:text-gray-500"
},
"data-auto-submit-form-target": "auto" %>
<%= f.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), "data-auto-submit-form-target": "auto" %>
<% end %>
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".settings") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-6">
<%= form_with model: [@account, @transaction], url: account_transaction_path, html: { class: "p-3 space-y-3", data: { controller: "auto-submit-form" } } do |f| %>
<div class="flex cursor-pointer items-center gap-2 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
</div>
<div class="relative inline-block select-none">
<%= f.check_box :excluded, class: "sr-only peer", "data-auto-submit-form-target": "auto" %>
<label for="account_transaction_excluded" class="maybe-switch"></label>
</div>
</div>
<% end %>
<% unless @transaction.transfer? %>
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".delete_title") %></h4>
<p class="text-gray-500"><%= t(".delete_subtitle") %></p>
</div>
<%= button_to t(".delete"),
account_transaction_path(@account, @transaction),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
data: { turbo_confirm: true, turbo_frame: "_top" } %>
</div>
<% end %>
</div>
</details>
</div>
</div>
<% end %>

View File

@@ -19,11 +19,11 @@
</section>
<section class="space-y-2">
<%= f.text_field :name, value: transfer.transactions.first&.name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
<%= f.text_field :name, value: transfer.name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
<%= f.collection_select :from_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %>
<%= f.collection_select :to_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %>
<%= f.money_field :amount_money, label: t(".amount"), required: true %>
<%= f.date_field :date, value: transfer.transactions.first&.date, label: t(".date"), required: true, max: Date.current %>
<%= f.date_field :date, value: transfer.date, label: t(".date"), required: true, max: Date.current %>
</section>
<section>

View File

@@ -1,34 +1,49 @@
<%= turbo_frame_tag dom_id(transfer), class: "block" do %>
<details class="group flex items-center text-gray-900 text-sm font-medium">
<summary class="flex items-center justify-between p-4">
<div class="flex items-center gap-4">
<%= button_to account_transfer_path(transfer),
method: :delete,
class: "flex items-center group/transfer",
data: {
turbo_frame: "_top",
turbo_confirm: {
title: t(".remove_title"),
body: t(".remove_body"),
confirm: t(".remove_confirm")
}
} do %>
<%= lucide_icon "arrow-left-right", class: "group-hover/transfer:hidden w-5 h-5 text-gray-500" %>
<%= lucide_icon "unlink", class: "group-hover/transfer:inline-block hidden w-5 h-5 text-gray-500" %>
<% end %>
<%# locals: (transfer:, selectable: true, editable: true, short: false, **opts) %>
<div class="max-w-full pr-10 select-none">
<%= tag.p t(".transfer_name", from_account: transfer.outflow_transaction&.account&.name, to_account: transfer.inflow_transaction&.account&.name) %>
<%= turbo_frame_tag dom_id(transfer) do %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="col-span-7 flex items-center">
<% if selectable %>
<%= check_box_tag dom_id(transfer, "selection"),
disabled: true,
class: "mr-3 cursor-not-allowed maybe-checkbox maybe-checkbox--light" %>
<% end %>
<%= tag.div class: short ? "max-w-[250px]" : "max-w-[325px]" do %>
<div class="flex items-center gap-2">
<%= circle_logo("T") %>
<%= tag.p transfer.name, class: "truncate text-gray-900" %>
</div>
</div>
<% end %>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<%= button_to account_transfer_path(transfer),
method: :delete,
class: "ml-2 flex items-center group/transfer hover:bg-gray-50 rounded-md p-1",
data: {
turbo_frame: "_top",
turbo_confirm: {
title: t(".remove_title"),
body: t(".remove_body"),
confirm: t(".remove_confirm")
}
} do %>
<div class="divide-y divide-alpha-black-200">
<% transfer.transactions.each do |transaction| %>
<%= render transaction, selectable: false, editable: false %>
<%= lucide_icon "link-2", class: "group-hover/transfer:hidden w-4 h-4 text-gray-500" %>
<%= lucide_icon "unlink", class: "group-hover/transfer:inline-block hidden w-4 h-4 text-gray-500" %>
<% end %>
</div>
</details>
<% unless short %>
<div class="col-span-3 flex items-center gap-2">
<%= circle_logo(transfer.from_name[0].upcase, size: "sm") %>
<span class="text-gray-500 font-medium">&rarr;</span>
<%= circle_logo(transfer.to_name[0].upcase, size: "sm") %>
</div>
<% end %>
<div class="ml-auto <%= short ? "col-span-5" : "col-span-2" %>">
<%= tag.p format_money(transfer.amount_money), class: "font-medium" %>
</div>
</div>
<% end %>

View File

@@ -1,23 +0,0 @@
<%# locals: (valuation:) %>
<%= form_with model: valuation,
data: { turbo_frame: "_top" },
url: valuation.new_record? ? account_valuations_path(valuation.account) : account_valuation_path(valuation.account, valuation),
builder: ActionView::Helpers::FormBuilder do |f| %>
<div class="grid grid-cols-10 p-4 items-center">
<div class="col-span-7 flex items-center gap-4">
<div class="w-8 h-8 rounded-full p-1.5 flex items-center justify-center bg-gray-500/5">
<%= lucide_icon("pencil-line", class: "w-4 h-4 text-gray-500") %>
</div>
<div class="w-full flex items-center justify-between gap-2">
<%= f.date_field :date, required: "required", max: Date.today, class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5 text-gray-900 text-sm" %>
<%= f.number_field :value, required: "required", placeholder: "0.00", step: "0.01", class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs text-gray-900 text-sm px-3 py-1.5 text-right" %>
<%= f.hidden_field :currency, value: valuation.account.currency %>
</div>
</div>
<div class="col-span-3 flex gap-2 justify-end items-center">
<%= link_to t(".cancel"), account_valuations_path(valuation.account), class: "text-sm text-gray-900 hover:text-gray-800 font-medium px-3 py-1.5" %>
<%= f.submit class: "bg-gray-50 rounded-lg font-medium px-3 py-1.5 cursor-pointer hover:bg-gray-100 text-sm" %>
</div>
</div>
<% end %>

View File

@@ -1,5 +0,0 @@
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="p-5 flex justify-center items-center">
<%= tag.p t(".loading"), class: "text-gray-500 animate-pulse text-sm" %>
</div>
</div>

View File

@@ -1,3 +0,0 @@
<%= turbo_frame_tag dom_id(@valuation) do %>
<%= render "form", valuation: @valuation %>
<% end %>

View File

@@ -1,4 +0,0 @@
<%= turbo_frame_tag dom_id(@valuation) do %>
<%= render "form", valuation: @valuation %>
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>
<% end %>

View File

@@ -1 +0,0 @@
<%= render "valuation", valuation: @valuation %>

View File

@@ -17,5 +17,7 @@
<%= lucide_icon("plus", class: "w-5 h-5") %>
<p class="text-sm font-medium"><%= t(".new") %></p>
<% end %>
<%= render "sync_all_button" %>
</div>
</header>

View File

@@ -0,0 +1,3 @@
<%= button_to sync_all_accounts_path, method: :post, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", title: "Sync All" do %>
<%= lucide_icon "refresh-cw", class: "w-5 h-5" %>
<% end %>

View File

@@ -24,6 +24,8 @@
<%= lucide_icon("plus", class: "w-5 h-5") %>
<p class="text-sm font-medium"><%= t(".new_account") %></p>
<% end %>
<%= render "sync_all_button" %>
</div>
</div>
</header>

View File

@@ -81,12 +81,12 @@
<div class="min-h-[800px]">
<% if selected_tab == "transactions" %>
<%= turbo_frame_tag dom_id(@account, "transactions"), src: account_transactions_path(@account) do %>
<%= render "account/transactions/loading" %>
<%= turbo_frame_tag dom_id(@account, "transactions"), src: transaction_account_entries_path(@account) do %>
<%= render "account/entries/loading" %>
<% end %>
<% else %>
<%= turbo_frame_tag dom_id(@account, "valuations"), src: account_valuations_path(@account) do %>
<%= render "account/valuations/loading" %>
<%= turbo_frame_tag dom_id(@account, "valuations"), src: valuation_account_entries_path(@account) do %>
<%= render "account/entries/loading" %>
<% end %>
<% end %>
</div>

View File

@@ -2,7 +2,7 @@
<% is_selected = category.id === @selected_category&.id %>
<%= content_tag :div, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %>
<%= button_to account_transaction_row_path(@transaction.account, @transaction, transaction: { category_id: category.id }), method: :patch, data: { turbo_frame: dom_id(@transaction) }, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
<%= button_to account_entry_path(@transaction.entry.account, @transaction.entry, account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: category.id } }), method: :patch, data: { turbo_frame: dom_id(@transaction.entry) }, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
<span class="w-5 h-5">
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
</span>

View File

@@ -25,10 +25,10 @@
<% end %>
<% if @transaction.category %>
<%= button_to account_transaction_row_path(@transaction.account, @transaction),
<%= button_to account_entry_path(@transaction.entry.account, @transaction.entry),
method: :patch,
data: { turbo_frame: dom_id(@transaction) },
params: { transaction: { category_id: nil } },
data: { turbo_frame: dom_id(@transaction.entry) },
params: { account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100" do %>
<%= lucide_icon "minus", class: "w-5 h-5" %>

View File

@@ -9,12 +9,11 @@
</div>
<div class="mb-8 space-y-4">
<% transactions = @import.dry_run %>
<% group_transactions_by_date(transactions).each do |date, group| %>
<%= render "account/transactions/transaction_group",
<% transaction_entries = @import.dry_run %>
<% transaction_entries.group_by(&:date).each do |date, transactions| %>
<%= render "account/entries/entry_group",
date: date,
transactions: group[:transactions],
transfers: group[:transfers],
entries: transactions,
show_tags: true,
selectable: false,
editable: false %>

View File

@@ -25,7 +25,7 @@
<% end %>
</div>
<div class="flex justify-between gap-4">
<%= previous_setting("Rules", account_transaction_rules_path) %>
<%= previous_setting("Rules", rules_transactions_path) %>
<%= next_setting("What's new", changelog_path) %>
</div>
</div>

View File

@@ -35,6 +35,6 @@
</div>
<div class="flex justify-between gap-4">
<%= previous_setting("Categories", categories_path) %>
<%= next_setting("Rules", account_transaction_rules_path) %>
<%= next_setting("Rules", rules_transactions_path) %>
</div>
</div>

View File

@@ -156,17 +156,16 @@
<section class="grid grid-cols-2 gap-4 items-baseline">
<div class="bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl space-y-4">
<h2 class="text-lg font-medium text-gray-900"><%= t(".transactions") %></h2>
<% if @transactions.empty? %>
<% if @transaction_entries.empty? %>
<div class="text-gray-500 flex items-center justify-center py-12">
<p><%= t(".no_transactions") %></p>
</div>
<% else %>
<div class="text-gray-500 p-1 space-y-1 bg-gray-25 rounded-xl">
<% group_transactions_by_date(@transactions).each do |date, group| %>
<%= render "account/transactions/transaction_group",
<% @transaction_entries.group_by(&:date).each do |date, transactions| %>
<%= render "account/entries/entry_group",
date: date,
transactions: group[:transactions],
transfers: group[:transfers],
entries: transactions,
selectable: false,
editable: false,
short: true %>

View File

@@ -56,7 +56,7 @@
<%= sidebar_link_to t(".merchants_label"), merchants_path, icon: "store" %>
</li>
<li>
<%= sidebar_link_to t(".rules_label"), account_transaction_rules_path, icon: "list-checks" %>
<%= sidebar_link_to t(".rules_label"), rules_transactions_path, icon: "list-checks" %>
</li>
<li>
<%= sidebar_link_to t(".imports_label"), imports_path, icon: "download" %>

View File

@@ -0,0 +1,13 @@
<%# locals: (name:, hex: nil, size: "md") %>
<% size_classes = {
"sm" => "w-6 h-6",
"md" => "w-8 h-8",
"lg" => "w-10 h-10",
"full" => "w-full h-full"
} %>
<%= tag.div style: mixed_hex_styles(hex),
class: [size_classes[size], "flex shrink-0 items-center justify-center rounded-full"] do %>
<%= tag.span name[0].upcase, class: ["font-medium", size == "sm" ? "text-xs" : "text-sm"] %>
<% end %>

View File

@@ -3,13 +3,7 @@
<p class="text-sm text-gray-500 font-medium"><%= label %></p>
<p class="text-gray-900 -space-x-0.5">
<% if value.is_a?(Money) %>
<span class="text-gray-500"><%= value.currency.symbol %></span>
<span class="<%= size == "lg" ? "text-xl" : "text-lg" %> font-medium"><%= format_money_without_symbol value, precision: 0 %></span>
<%- if value.currency.default_precision.positive? -%>
<span class="text-gray-500">
<%= value.currency.separator %><%= value.cents_str %>
</span>
<% end %>
<span class="<%= size == "lg" ? "text-xl" : "text-lg" %> font-medium"><%= format_money value %></span>
<% else %>
<span class="<%= size == "lg" ? "text-xl" : "text-lg" %> font-medium"><%= is_percentage ? number_to_percentage(value, precision: 2) : value %></span>
<% end %>

View File

@@ -1,4 +1,4 @@
<%= form_with model: @transaction, url: transactions_path, scope: "transaction", data: { turbo_frame: "_top" } do |f| %>
<%= form_with model: @entry, url: transactions_path, data: { turbo_frame: "_top" } do |f| %>
<section>
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
<%= radio_tab_tag form: f, name: :nature, value: :expense, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "expense" || params[:nature].nil? %>
@@ -14,7 +14,10 @@
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
<%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true %>
<%= f.money_field :amount_money, label: t(".amount"), required: true %>
<%= f.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %>
<%= f.hidden_field :entryable_type, value: "Account::Transaction" %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %>
<% end %>
<%= f.date_field :date, label: t(".date"), required: true, max: Date.today %>
</section>

View File

@@ -1,39 +1,39 @@
<%# locals: (pagy:) %>
<nav class="flex items-center justify-between px-4 mt-4 sm:px-0 w-full">
<div class="flex">
<nav class="flex w-full items-center justify-between">
<div class="flex items-center gap-1">
<div>
<% if pagy.prev %>
<%= link_to pagy_url_for(pagy, pagy.prev), class: "inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
<%= link_to pagy_url_for(pagy, pagy.prev), class: "inline-flex items-center p-2 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
<%= lucide_icon("chevron-left", class: "w-5 h-5 text-gray-500") %>
<% end %>
<% else %>
<div class="inline-flex items-center px-3 py-3 text-sm font-medium hover:border-gray-300">
<div class="inline-flex items-center p-2 text-sm font-medium hover:border-gray-300">
<%= lucide_icon("chevron-left", class: "w-5 h-5 text-gray-200") %>
</div>
<% end %>
</div>
<div class="bg-gray-25 rounded-xl">
<div class="rounded-xl p-1 bg-gray-25">
<% pagy.series.each do |series_item| %>
<% if series_item.is_a?(Integer) %>
<%= link_to pagy_url_for(pagy, series_item), class: "inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
<%= link_to pagy_url_for(pagy, series_item), class: "rounded-md px-2 py-1 inline-flex items-center text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
<%= series_item %>
<% end %>
<% elsif series_item.is_a?(String) %>
<%= link_to pagy_url_for(pagy, series_item), class: "shadow-lg ring-2 ring-inset ring-gray-200 rounded-xl bg-white inline-flex items-center m-1 px-4 py-2 text-sm font-medium text-gray-900" do %>
<%= link_to pagy_url_for(pagy, series_item), class: "rounded-md px-2 py-1 bg-white border border-alpha-black-25 shadow-xs inline-flex items-center text-sm font-medium text-gray-900" do %>
<%= series_item %>
<% end %>
<% elsif series_item == :gap %>
<span class="inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500">...</span>
<span class="inline-flex items-center px-2 py-1 text-sm font-medium text-gray-500">...</span>
<% end %>
<% end %>
</div>
<div>
<% if pagy.next %>
<%= link_to pagy_url_for(pagy, pagy.next), class: "inline-flex items-center px-3 py-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
<%= link_to pagy_url_for(pagy, pagy.next), class: "inline-flex items-center p-2 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
<% end %>
<% else %>
<div class="inline-flex items-center px-3 py-3 text-sm font-medium hover:border-gray-300">
<div class="inline-flex items-center p-2 text-sm font-medium hover:border-gray-300">
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-200") %>
</div>
<% end %>
@@ -47,7 +47,7 @@
data: { controller: "auto-submit-form" } do |f| %>
<%= f.label :per_page, t(".rows_per_page"), class: "text-sm text-gray-500" %>
<%= f.select :per_page,
options_for_select(["10", "20", "30", "50"], params[:per_page]),
options_for_select(["10", "20", "30", "50"], pagy.items),
{},
class: "py-1.5 pr-8 text-sm text-gray-900 font-medium border border-gray-200 rounded-lg focus:border-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900",
data: { "auto-submit-form-target": "auto" } %>

View File

@@ -28,44 +28,26 @@
<div class="pb-6 space-y-2">
<%= form.date_field :date, label: t(".date_label"), max: Date.current %>
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".details") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="space-y-2">
<%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-gray-400" } %>
<%= form.collection_select :merchant_id, Current.family.merchants.alphabetically, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-gray-400" } %>
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".additional") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div>
<%= form.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), rows: 5 %>
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".settings") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="flex cursor-pointer items-center justify-between gap-4 p-3 pb-6">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
</div>
<div class="relative inline-block select-none">
<%= form.check_box :excluded, class: "sr-only peer" %>
<label for="bulk_update_excluded" class="maybe-switch"></label>
</div>
</div>
</details>
</div>
</div>
</div>
</div>
<div class="flex justify-end items-center gap-2">
<%= link_to t(".cancel"), transactions_path, class: "text-sm font-medium text-gray-900 px-3 py-2" %>

View File

@@ -3,19 +3,20 @@
<%= render "summary", totals: @totals %>
<div id="transactions" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".transaction") %>" class="overflow-y-auto flex flex-col bg-white rounded-xl border border-alpha-black-25 shadow-xs pb-4">
<div class="p-4 pb-0">
<%= render partial: "transactions/searches/search", locals: { transactions: @transactions } %>
</div>
<div id="transactions"
data-controller="bulk-select"
data-bulk-select-resource-value="<%= t(".transaction") %>"
class="overflow-y-auto flex flex-col bg-white rounded-xl border border-alpha-black-25 shadow-xs p-4">
<%= render "transactions/searches/search" %>
<% if @transactions.present? %>
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
<%= render "account/transactions/selection_bar" %>
<% if @transaction_entries.present? %>
<div hidden id="entry-selection-bar" data-bulk-select-target="selectionBar">
<%= render "account/entries/selection_bar" %>
</div>
<div class="grow overflow-y-auto px-4">
<div class="grow overflow-y-auto">
<div class="grid grid-cols-12 bg-gray-25 rounded-xl px-5 py-3 text-xs uppercase font-medium text-gray-500 items-center mb-4">
<div class="pl-0.5 col-span-4 flex items-center gap-4">
<%= check_box_tag "selection_transaction",
<%= check_box_tag "selection_entry",
class: "maybe-checkbox maybe-checkbox--light",
data: { action: "bulk-select#togglePageSelection" } %>
<p class="col-span-4">transaction</p>
@@ -26,19 +27,17 @@
<p class="col-span-2 justify-self-end">amount</p>
</div>
<div class="space-y-6">
<% group_transactions_by_date(@transactions).each do |date, group| %>
<%= render "account/transactions/transaction_group", date:, transactions: group[:transactions], transfers: group[:transfers] %>
<% @transaction_entries.group_by(&:date).each do |date, entries| %>
<%= render "account/entries/entry_group", date:, entries: %>
<% end %>
</div>
</div>
<% else %>
<%= render "account/transactions/empty" %>
<%= render "account/entries/empty" %>
<% end %>
<div class="px-4">
<% if @pagy.pages > 1 %>
<%= render "pagination", pagy: @pagy %>
<% end %>
<div class="pt-4">
<%= render "pagination", pagy: @pagy %>
</div>
</div>
</div>

View File

@@ -5,6 +5,6 @@
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", transaction: @transaction %>
<%= render "form", transaction: @transaction, entry: @entry %>
</article>
<% end %>

View File

@@ -1,4 +1,3 @@
<%# locals: (transactions:) %>
<%= form_with url: transactions_path,
id: "transactions-search",
scope: :q,

View File

@@ -1,13 +1,13 @@
<%= render partial: "transactions/searches/form", locals: { transactions: transactions } %>
<%= render "transactions/searches/form" %>
<ul id="transaction-search-filters" class="flex items-center flex-wrap gap-2">
<% @q.each do |param_key, param_value| %>
<% unless param_value.blank? %>
<div class="pb-4">
<% Array(param_value).each do |value| %>
<%= render partial: "transactions/searches/filters/badge", locals: { param_key: param_key, param_value: value } %>
<% end %>
</div>
<div class="pb-4">
<% Array(param_value).each do |value| %>
<%= render partial: "transactions/searches/filters/badge", locals: { param_key: param_key, param_value: value } %>
<% end %>
</div>
<% end %>
<% end %>
</ul>

74
config/brakeman.ignore Normal file
View File

@@ -0,0 +1,74 @@
{
"ignored_warnings": [
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
"fingerprint": "6179565a9eb1786348e6bbaf5d838b77f9075551930a6ca8ba33fbbf6d2adf26",
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/account/entries/show.html.erb",
"line": 1,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(partial => permitted_entryable_partial_path(Current.family.accounts.find(params[:account_id]).entries.find(params[:id]), \"show\"), { :locals => ({ :entry => Current.family.accounts.find(params[:account_id]).entries.find(params[:id]) }) })",
"render_path": [
{
"type": "controller",
"class": "Account::EntriesController",
"method": "show",
"line": 42,
"file": "app/controllers/account/entries_controller.rb",
"rendered": {
"name": "account/entries/show",
"file": "app/views/account/entries/show.html.erb"
}
}
],
"location": {
"type": "template",
"template": "account/entries/show"
},
"user_input": "params[:id]",
"confidence": "Weak",
"cwe_id": [
22
],
"note": ""
},
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
"fingerprint": "7a182d062523a7fe890fbe5945c0004aeec1044ac764430f1d464326e5fa2710",
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/account/entries/edit.html.erb",
"line": 2,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(action => permitted_entryable_partial_path(Current.family.accounts.find(params[:account_id]).entries.find(params[:id]), \"edit\"), { :entry => Current.family.accounts.find(params[:account_id]).entries.find(params[:id]) })",
"render_path": [
{
"type": "controller",
"class": "Account::EntriesController",
"method": "edit",
"line": 29,
"file": "app/controllers/account/entries_controller.rb",
"rendered": {
"name": "account/entries/edit",
"file": "app/views/account/entries/edit.html.erb"
}
}
],
"location": {
"type": "template",
"template": "account/entries/edit"
},
"user_input": "params[:id]",
"confidence": "Weak",
"cwe_id": [
22
],
"note": ""
}
],
"updated": "2024-06-30 12:52:10 -0400",
"brakeman_version": "6.1.2"
}

View File

@@ -10,7 +10,7 @@ module Maybe
private
def semver
"0.1.0-alpha.8"
"0.1.0-alpha.9"
end
end
end

View File

@@ -0,0 +1,5 @@
---
en:
account:
transfer:
name: Transfer from %{from_account} to %{to_account}

View File

@@ -0,0 +1,78 @@
---
en:
account:
entries:
create:
success: "%{name} created"
destroy:
success: Entry deleted
empty:
description: Try adding an entry, editing filters or refining your search
title: No entries found
entryables:
transaction:
show:
account_label: Account
account_placeholder: Select an account
additional: Additional
amount: Amount
category_label: Category
category_placeholder: Select a category
date_label: Date
delete: Delete
delete_subtitle: This permanently deletes the transaction, affects your
historical balances, and cannot be undone.
delete_title: Delete transaction
exclude_subtitle: This excludes the transaction from any in-app features
or analytics.
exclude_title: Exclude transaction
merchant_label: Merchant
merchant_placeholder: Select a merchant
name_label: Name
nature: Transaction type
note_label: Notes
note_placeholder: Enter a note
overview: Overview
settings: Settings
tags_label: Select one or more tags
transaction:
remove_transfer: Remove transfer
remove_transfer_body: This will remove the transfer from this transaction
remove_transfer_confirm: Confirm
valuation:
form:
cancel: Cancel
valuation:
confirm_accept: Delete entry
confirm_body_html: "<p>Deleting this entry will remove it from the accounts
history which will impact different parts of your account. This includes
the net worth and account graphs.</p></br><p>The only way youll be
able to add this entry back is by re-entering it manually via a new
entry</p>"
confirm_title: Delete Entry?
delete_entry: Delete entry
edit_entry: Edit entry
no_change: No change
start_balance: Starting balance
value_update: Value update
loading:
loading: Loading entries...
selection_bar:
mark_transfers: Mark as transfers?
mark_transfers_confirm: Mark as transfers
mark_transfers_message: By marking transactions as transfers, they will no
longer be included in income or spending calculations.
transactions:
new: New transaction
no_transactions: No transactions for this account yet.
transaction: transaction
transactions: Transactions
update:
success: Entry updated
valuations:
change: change
date: date
new_entry: New entry
no_valuations: No valuations for this account yet
valuations: Value history
value: value

View File

@@ -1,49 +0,0 @@
---
en:
account:
transactions:
destroy:
success: Transaction deleted successfully
empty:
description: Try adding a transaction, editing filters or refining your search
title: No transactions found
index:
new: New transaction
no_transactions: No transactions for this account yet.
transaction: transaction
transactions: Transactions
loading:
loading: Loading transactions...
selection_bar:
mark_transfers: Mark as transfers?
mark_transfers_confirm: Mark as transfers
mark_transfers_message: By marking transactions as transfers, they will no
longer be included in income or spending calculations.
show:
account_label: Account
account_placeholder: Select an account
additional: Additional
category_label: Category
category_placeholder: Select a category
date_label: Date
delete: Delete
delete_subtitle: This permanently deletes the transaction, affects your historical
balances, and cannot be undone.
delete_title: Delete transaction
exclude_subtitle: This excludes the transaction from any in-app features or
analytics.
exclude_title: Exclude transaction
merchant_label: Merchant
merchant_placeholder: Select a merchant
name_label: Name
note_label: Notes
note_placeholder: Enter a note
overview: Overview
settings: Settings
tags_label: Select one or more tags
transaction:
remove_transfer: Remove transfer
remove_transfer_body: This will remove the transfer from this transaction
remove_transfer_confirm: Confirm
update:
success: Transaction updated successfully

View File

@@ -25,4 +25,3 @@ en:
remove the transfer.
remove_confirm: Confirm
remove_title: Remove transfer?
transfer_name: Transfer from %{from_account} to %{to_account}

View File

@@ -1,31 +0,0 @@
---
en:
account:
valuations:
destroy:
success: Valuation deleted
form:
cancel: Cancel
index:
change: change
date: date
new_entry: New entry
no_valuations: No valuations for this account yet
valuations: Value history
value: value
loading:
loading: Loading history...
update:
success: Valuation updated
valuation:
confirm_accept: Delete entry
confirm_body_html: "<p>Deleting this entry will remove it from the accounts
history which will impact different parts of your account. This includes
the net worth and account graphs.</p></br><p>The only way youll be able
to add this entry back is by re-entering it manually via a new entry</p>"
confirm_title: Delete Entry?
delete_entry: Delete entry
edit_entry: Edit entry
no_change: No change
start_balance: Starting balance
value_update: Value update

View File

@@ -65,5 +65,8 @@ en:
new: New account
sync:
success: Account sync started
sync_all:
no_accounts_to_sync: No accounts were eligible for syncing.
success: Successfully queued %{count} accounts for syncing.
update:
success: Account updated

View File

@@ -4,23 +4,18 @@ en:
bulk_delete:
success: "%{count} transactions deleted"
bulk_edit:
additional: Additional
cancel: Cancel
category_label: Category
category_placeholder: Select a category
date_label: Date
exclude_subtitle: This excludes the transaction from any in-app features or
analytics.
exclude_title: Exclude transaction
details: Details
merchant_label: Merchant
merchant_placeholder: Select a merchant
note_label: Notes
note_placeholder: Enter a note that will be applied to selected transactions
overview: Overview
save: Save
settings: Settings
bulk_update:
failure: Could not update transactions
success: "%{count} transactions updated"
create:
success: New transaction created successfully

View File

@@ -63,6 +63,7 @@ Rails.application.routes.draw do
collection do
get :summary
get :list
post :sync_all
end
member do
@@ -72,10 +73,11 @@ Rails.application.routes.draw do
scope module: :account do
resource :logo, only: :show
resources :valuations
resources :transactions, only: %i[ index show update destroy ] do
resource :row, only: %i[ show update ], module: :transaction
resources :entries, except: :index do
collection do
get "transactions", as: :transaction
get "valuations", as: :valuation
end
end
end
end
@@ -87,6 +89,7 @@ Rails.application.routes.draw do
post "bulk_update"
post "mark_transfers"
post "unmark_transfers"
get "rules"
end
end

View File

@@ -0,0 +1,15 @@
class CreateAccountEntries < ActiveRecord::Migration[7.2]
def change
create_table :account_entries, id: :uuid do |t|
t.references :account, null: false, foreign_key: true, type: :uuid
t.string :entryable_type
t.uuid :entryable_id
t.decimal :amount, precision: 19, scale: 4
t.string :currency
t.date :date
t.string :name
t.timestamps
end
end
end

View File

@@ -0,0 +1,28 @@
class MigrateEntryables < ActiveRecord::Migration[7.2]
def change
reversible do |dir|
dir.up do
# Migrate Account::Transaction data
execute <<-SQL.squish
INSERT INTO account_entries (name, date, amount, currency, account_id, entryable_type, entryable_id, created_at, updated_at)
SELECT name, date, amount, currency, account_id, 'Account::Transaction', id, created_at, updated_at
FROM account_transactions
SQL
# Migrate Account::Valuation data
execute <<-SQL.squish
INSERT INTO account_entries (name, date, amount, currency, account_id, entryable_type, entryable_id, created_at, updated_at)
SELECT 'Manual valuation', date, value, currency, account_id, 'Account::Valuation', id, created_at, updated_at
FROM account_valuations
SQL
end
dir.down do
# Delete the entries from account_entries
execute <<-SQL.squish
DELETE FROM account_entries WHERE entryable_type IN ('Account::Transaction', 'Account::Valuation')
SQL
end
end
end
end

View File

@@ -0,0 +1,58 @@
class RemoveOldColumnsFromEntryables < ActiveRecord::Migration[7.2]
def change
reversible do |dir|
dir.up do
# Remove old columns from Account::Transaction
remove_column :account_transactions, :name
remove_column :account_transactions, :date
remove_column :account_transactions, :amount
remove_column :account_transactions, :currency
remove_column :account_transactions, :account_id
# Remove old columns from Account::Valuation
remove_column :account_valuations, :date
remove_column :account_valuations, :value
remove_column :account_valuations, :currency
remove_column :account_valuations, :account_id
end
dir.down do
# Add old columns back to Account::Transaction
add_column :account_transactions, :name, :string
add_column :account_transactions, :date, :date
add_column :account_transactions, :amount, :decimal, precision: 19, scale: 4
add_column :account_transactions, :currency, :string
add_column :account_transactions, :account_id, :uuid
# Add old columns back to Account::Valuation
add_column :account_valuations, :date, :date
add_column :account_valuations, :value, :decimal, precision: 19, scale: 4
add_column :account_valuations, :currency, :string
add_column :account_valuations, :account_id, :uuid
# Repopulate data for Account::Transaction
execute <<-SQL.squish
UPDATE account_transactions at
SET name = ae.name,
date = ae.date,
amount = ae.amount,
currency = ae.currency,
account_id = ae.account_id
FROM account_entries ae
WHERE ae.entryable_type = 'Account::Transaction' AND ae.entryable_id = at.id
SQL
# Repopulate data for Account::Valuation
execute <<-SQL.squish
UPDATE account_valuations av
SET date = ae.date,
value = ae.amount,
currency = ae.currency,
account_id = ae.account_id
FROM account_entries ae
WHERE ae.entryable_type = 'Account::Valuation' AND ae.entryable_id = av.id
SQL
end
end
end
end

View File

@@ -0,0 +1,39 @@
class MoveTransfersAssociationFromTransactionsToEntries < ActiveRecord::Migration[7.2]
def change
reversible do |dir|
dir.up do
add_reference :account_entries, :transfer, foreign_key: { to_table: :account_transfers }, type: :uuid
add_column :account_entries, :marked_as_transfer, :boolean, default: false, null: false
execute <<-SQL.squish
UPDATE account_entries
SET transfer_id = transactions.transfer_id,
marked_as_transfer = transactions.marked_as_transfer
FROM account_transactions AS transactions
WHERE account_entries.entryable_id = transactions.id
AND account_entries.entryable_type = 'Account::Transaction'
SQL
remove_reference :account_transactions, :transfer, foreign_key: { to_table: :account_transfers }, type: :uuid
remove_column :account_transactions, :marked_as_transfer
end
dir.down do
add_reference :account_transactions, :transfer, foreign_key: { to_table: :account_transfers }, type: :uuid
add_column :account_transactions, :marked_as_transfer, :boolean, default: false, null: false
execute <<-SQL.squish
UPDATE account_transactions
SET transfer_id = account_entries.transfer_id,
marked_as_transfer = account_entries.marked_as_transfer
FROM account_entries
WHERE account_entries.entryable_id = account_transactions.id
AND account_entries.entryable_type = 'Account::Transaction'
SQL
remove_reference :account_entries, :transfer, foreign_key: { to_table: :account_transfers }, type: :uuid
remove_column :account_entries, :marked_as_transfer
end
end
end
end

38
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: 2024_06_21_212528) do
ActiveRecord::Schema[7.2].define(version: 2024_06_28_104551) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -32,24 +32,31 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_21_212528) do
t.index ["account_id"], name: "index_account_balances_on_account_id"
end
create_table "account_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "name"
t.date "date", null: false
t.decimal "amount", precision: 19, scale: 4, null: false
t.string "currency", default: "USD", null: false
create_table "account_entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "account_id", null: false
t.string "entryable_type"
t.uuid "entryable_id"
t.decimal "amount", precision: 19, scale: 4
t.string "currency"
t.date "date"
t.string "name"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.uuid "transfer_id"
t.boolean "marked_as_transfer", default: false, null: false
t.index ["account_id"], name: "index_account_entries_on_account_id"
t.index ["transfer_id"], name: "index_account_entries_on_transfer_id"
end
create_table "account_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.uuid "category_id"
t.boolean "excluded", default: false
t.text "notes"
t.uuid "merchant_id"
t.uuid "transfer_id"
t.boolean "marked_as_transfer", default: false, null: false
t.index ["account_id"], name: "index_account_transactions_on_account_id"
t.index ["category_id"], name: "index_account_transactions_on_category_id"
t.index ["merchant_id"], name: "index_account_transactions_on_merchant_id"
t.index ["transfer_id"], name: "index_account_transactions_on_transfer_id"
end
create_table "account_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -58,14 +65,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_21_212528) do
end
create_table "account_valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "account_id", null: false
t.date "date", null: false
t.decimal "value", precision: 19, scale: 4, null: false
t.string "currency", default: "USD", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "date"], name: "index_account_valuations_on_account_id_and_date", unique: true
t.index ["account_id"], name: "index_account_valuations_on_account_id"
end
create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -352,11 +353,10 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_21_212528) do
end
add_foreign_key "account_balances", "accounts", on_delete: :cascade
add_foreign_key "account_transactions", "account_transfers", column: "transfer_id"
add_foreign_key "account_transactions", "accounts", on_delete: :cascade
add_foreign_key "account_entries", "account_transfers", column: "transfer_id"
add_foreign_key "account_entries", "accounts"
add_foreign_key "account_transactions", "categories", on_delete: :nullify
add_foreign_key "account_transactions", "merchants"
add_foreign_key "account_valuations", "accounts", on_delete: :cascade
add_foreign_key "accounts", "families"
add_foreign_key "accounts", "institutions"
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"

View File

@@ -32,7 +32,7 @@ services:
image: ghcr.io/maybe-finance/maybe:latest
volumes:
- ./storage:/rails/storage
- app-storage:/rails/storage
ports:
- 3000:3000
@@ -70,4 +70,5 @@ services:
retries: 5
volumes:
app-storage:
postgres-data:

View File

@@ -1,13 +1,26 @@
namespace :demo_data do
desc "Creates or resets demo data used in development environment"
task reset_empty: :environment do
Family.all.each do |family|
family.destroy
end
family = Family.create(name: "Demo Family")
family.users.create! \
email: "user@maybe.local",
password: "password",
first_name: "Demo",
last_name: "User"
end
task reset: :environment do
family = Family.find_or_create_by(name: "Demo Family")
family.accounts.delete_all
family.accounts.destroy_all
ExchangeRate.delete_all
family.categories.delete_all
family.categories.destroy_all
Tagging.delete_all
family.tags.delete_all
family.tags.destroy_all
Category.create_default_categories(family)
user = User.find_or_create_by(email: "user@maybe.local") do |u|
@@ -252,25 +265,44 @@ namespace :demo_data do
{ date: 1.month.ago.to_date, value: 1000 }
]
# Insert valuations
retirement.valuations.insert_all(retirement_valuations)
brokerage.valuations.insert_all(brokerage_valuations)
crypto.valuations.insert_all(crypto_valuations)
mortgage.valuations.insert_all(mortgage_valuations)
house.valuations.insert_all(house_valuations)
main_car.valuations.insert_all(main_car_valuations)
second_car.valuations.insert_all(second_car_valuations)
cash.valuations.insert_all(cash_valuations)
personal_iou.valuations.insert_all(personal_iou_valuations)
accounts = [
[ empty_account, [], [] ],
[ multi_currency_checking, multi_currency_checking_transactions, [] ],
[ checking, checking_transactions, [] ],
[ savings, savings_transactions, [] ],
[ credit_card, credit_card_transactions, [] ],
[ retirement, [], retirement_valuations ],
[ euro_savings, euro_savings_transactions, [] ],
[ brokerage, [], brokerage_valuations ],
[ crypto, [], crypto_valuations ],
[ mortgage, mortgage_transactions, mortgage_valuations ],
[ main_car, [], main_car_valuations ],
[ cash, [], cash_valuations ],
[ car_loan, car_loan_transactions, [] ],
[ house, [], house_valuations ],
[ personal_iou, [], personal_iou_valuations ],
[ second_car, [], second_car_valuations ]
]
# Insert transactions
multi_currency_checking.transactions.insert_all(multi_currency_checking_transactions)
checking.transactions.insert_all(checking_transactions)
savings.transactions.insert_all(savings_transactions)
euro_savings.transactions.insert_all(euro_savings_transactions)
credit_card.transactions.insert_all(credit_card_transactions)
mortgage.transactions.insert_all(mortgage_transactions)
car_loan.transactions.insert_all(car_loan_transactions)
accounts.each do |account, transactions, valuations|
transactions.each do |transaction|
account.entries.create! \
name: transaction[:name],
date: transaction[:date],
amount: transaction[:amount],
currency: transaction[:currency] || "USD",
entryable: Account::Transaction.new(category: family.categories.first, tags: [ Tag.first ])
end
valuations.each do |valuation|
account.entries.create! \
name: "Manual valuation",
date: valuation[:date],
amount: valuation[:value],
currency: valuation[:currency] || "USD",
entryable: Account::Valuation.new
end
end
# Tag a few transactions
emergency_fund_tag = Tag.find_by(name: "Emergency Fund")

View File

@@ -0,0 +1,133 @@
require "test_helper"
class Account::EntriesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@account = accounts(:savings)
@transaction_entry = @account.entries.account_transactions.first
@valuation_entry = @account.entries.account_valuations.first
end
test "should edit valuation entry" do
get edit_account_entry_url(@account, @valuation_entry)
assert_response :success
end
test "should show transaction entry" do
get account_entry_url(@account, @transaction_entry)
assert_response :success
end
test "should show valuation entry" do
get account_entry_url(@account, @valuation_entry)
assert_response :success
end
test "should get list of transaction entries" do
get transaction_account_entries_url(@account)
assert_response :success
end
test "should get list of valuation entries" do
get valuation_account_entries_url(@account)
assert_response :success
end
test "gets new entry by type" do
get new_account_entry_url(@account, entryable_type: "Account::Valuation")
assert_response :success
end
test "should create valuation" do
assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 do
post account_entries_url(@account), params: {
account_entry: {
name: "Manual valuation",
amount: 19800,
date: Date.current,
currency: @account.currency,
entryable_type: "Account::Valuation",
entryable_attributes: {}
}
}
end
assert_equal "Valuation created", flash[:notice]
assert_enqueued_with job: AccountSyncJob
assert_redirected_to account_path(@account)
end
test "error when valuation already exists for date" do
assert_no_difference_in_entries do
post account_entries_url(@account), params: {
account_entry: {
amount: 19800,
date: @valuation_entry.date,
currency: @valuation_entry.currency,
entryable_type: "Account::Valuation",
entryable_attributes: {}
}
}
end
assert_equal "Date has already been taken", flash[:error]
assert_redirected_to account_path(@account)
end
test "can update entry without entryable attributes" do
assert_no_difference_in_entries do
patch account_entry_url(@account, @valuation_entry), params: {
account_entry: {
name: "Updated name"
}
}
end
assert_redirected_to account_entry_url(@account, @valuation_entry)
assert_enqueued_with(job: AccountSyncJob)
end
test "should update transaction entry with entryable attributes" do
assert_no_difference_in_entries do
patch account_entry_url(@account, @transaction_entry), params: {
account_entry: {
name: "Updated name",
date: Date.current,
currency: "USD",
amount: 20,
entryable_type: @transaction_entry.entryable_type,
entryable_attributes: {
id: @transaction_entry.entryable_id,
tag_ids: [ Tag.first.id, Tag.second.id ],
category_id: Category.first.id,
merchant_id: Merchant.first.id,
notes: "test notes",
excluded: false
}
}
}
end
assert_redirected_to account_entry_url(@account, @transaction_entry)
assert_enqueued_with(job: AccountSyncJob)
end
test "should destroy transaction entry" do
[ @transaction_entry, @valuation_entry ].each do |entry|
assert_difference -> { Account::Entry.count } => -1, -> { entry.entryable_class.count } => -1 do
delete account_entry_url(@account, entry)
end
assert_redirected_to account_url(@account)
assert_enqueued_with(job: AccountSyncJob)
end
end
private
# Simple guard to verify that nested attributes are passed the record ID to avoid new creation of record
# See `update_only` option of accepts_nested_attributes_for
def assert_no_difference_in_entries(&block)
assert_no_difference [ "Account::Entry.count", "Account::Transaction.count", "Account::Valuation.count" ], &block
end
end

View File

@@ -1,40 +0,0 @@
require "test_helper"
class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@transaction = account_transactions(:checking_one)
@account = @transaction.account
@recent_transactions = @user.family.transactions.ordered.limit(20).to_a
end
test "should show transaction" do
get account_transaction_url(@transaction.account, @transaction)
assert_response :success
end
test "should update transaction" do
patch account_transaction_url(@transaction.account, @transaction), params: {
account_transaction: {
account_id: @transaction.account_id,
amount: @transaction.amount,
currency: @transaction.currency,
date: @transaction.date,
name: @transaction.name,
tag_ids: [ Tag.first.id, Tag.second.id ]
}
}
assert_redirected_to account_transaction_url(@transaction.account, @transaction)
assert_enqueued_with(job: AccountSyncJob)
end
test "should destroy transaction" do
assert_difference("Account::Transaction.count", -1) do
delete account_transaction_url(@transaction.account, @transaction)
end
assert_redirected_to account_url(@transaction.account)
assert_enqueued_with(job: AccountSyncJob)
end
end

View File

@@ -22,6 +22,7 @@ class Account::TransfersControllerTest < ActionDispatch::IntegrationTest
name: "Test Transfer"
}
}
assert_enqueued_with job: AccountSyncJob
end
end

View File

@@ -1,71 +0,0 @@
require "test_helper"
class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@valuation = account_valuations(:savings_one)
@account = @valuation.account
end
test "get valuations for an account" do
get account_valuations_url(@account)
assert_response :success
end
test "new" do
get new_account_valuation_url(@account)
assert_response :success
end
test "should create valuation" do
assert_difference("Account::Valuation.count") do
post account_valuations_url(@account), params: {
account_valuation: {
value: 19800,
date: Date.current
}
}
end
assert_equal "Valuation created", flash[:notice]
assert_enqueued_with job: AccountSyncJob
assert_redirected_to account_path(@account)
end
test "error when valuation already exists for date" do
assert_difference("Account::Valuation.count", 0) do
post account_valuations_url(@account), params: {
account_valuation: {
value: 19800,
date: @valuation.date
}
}
end
assert_equal "Date has already been taken", flash[:error]
assert_redirected_to account_path(@account)
end
test "should update valuation" do
patch account_valuation_url(@account, @valuation), params: {
account_valuation: {
value: 19550,
date: Date.current
}
}
assert_equal "Valuation updated", flash[:notice]
assert_enqueued_with job: AccountSyncJob
assert_redirected_to account_path(@account)
end
test "should destroy valuation" do
assert_difference("Account::Valuation.count", -1) do
delete account_valuation_url(@account, @valuation)
end
assert_equal "Valuation deleted", flash[:notice]
assert_enqueued_with job: AccountSyncJob
assert_redirected_to account_path(@account)
end
end

View File

@@ -30,6 +30,12 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to account_url(@account)
end
test "can sync all accounts" do
post sync_all_accounts_path
assert_redirected_to accounts_url
assert_equal "Successfully queued #{ @user.family.accounts.size } accounts for syncing.", flash[:notice]
end
test "should update account" do
patch account_url(@account), params: {
account: {
@@ -44,7 +50,7 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
end
test "should create an account" do
assert_difference [ "Account.count", "Account::Valuation.count" ], 1 do
assert_difference [ "Account.count", "Account::Valuation.count", "Account::Entry.count" ], 1 do
post accounts_path, params: {
account: {
accountable_type: "Depository",

View File

@@ -3,8 +3,8 @@ require "test_helper"
class TransactionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@transaction = account_transactions(:checking_one)
@recent_transactions = @user.family.transactions.ordered.limit(20).to_a
@transaction_entry = account_entries(:checking_one)
@recent_transaction_entries = @user.family.entries.account_transactions.reverse_chronological.limit(20).to_a
end
test "should get new" do
@@ -13,88 +13,96 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
end
test "prefills account_id" do
get new_transaction_url(account_id: @transaction.account.id)
get new_transaction_url(account_id: @transaction_entry.account.id)
assert_response :success
assert_select "option[selected][value='#{@transaction.account.id}']"
assert_select "option[selected][value='#{@transaction_entry.account.id}']"
end
test "should create transaction" do
account = @user.family.accounts.first
transaction_params = {
entry_params = {
account_id: account.id,
amount: 100.45,
currency: "USD",
date: Date.current,
name: "Test transaction"
name: "Test transaction",
entryable_type: "Account::Transaction",
entryable_attributes: { category_id: categories(:food_and_drink).id }
}
assert_difference("Account::Transaction.count") do
post transactions_url, params: { transaction: transaction_params }
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do
post transactions_url, params: { account_entry: entry_params }
end
assert_equal transaction_params[:amount].to_d, Account::Transaction.order(created_at: :desc).first.amount
assert_equal entry_params[:amount].to_d, Account::Transaction.order(created_at: :desc).first.entry.amount
assert_equal "New transaction created successfully", flash[:notice]
assert_enqueued_with(job: AccountSyncJob)
assert_redirected_to account_url(account)
end
test "expenses are positive" do
assert_difference("Account::Transaction.count") do
assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], 1) do
post transactions_url, params: {
transaction: {
account_entry: {
nature: "expense",
account_id: @transaction.account_id,
amount: @transaction.amount,
currency: @transaction.currency,
date: @transaction.date,
name: @transaction.name
account_id: @transaction_entry.account_id,
amount: @transaction_entry.amount,
currency: @transaction_entry.currency,
date: @transaction_entry.date,
name: @transaction_entry.name,
entryable_type: "Account::Transaction",
entryable_attributes: {}
}
}
end
created_transaction = Account::Transaction.order(created_at: :desc).first
created_entry = Account::Entry.order(created_at: :desc).first
assert_redirected_to account_url(@transaction.account)
assert created_transaction.amount.positive?, "Amount should be positive"
assert_redirected_to account_url(@transaction_entry.account)
assert created_entry.amount.positive?, "Amount should be positive"
end
test "incomes are negative" do
assert_difference("Account::Transaction.count") do
post transactions_url, params: {
transaction: {
account_entry: {
nature: "income",
account_id: @transaction.account_id,
amount: @transaction.amount,
currency: @transaction.currency,
date: @transaction.date,
name: @transaction.name
account_id: @transaction_entry.account_id,
amount: @transaction_entry.amount,
currency: @transaction_entry.currency,
date: @transaction_entry.date,
name: @transaction_entry.name,
entryable_type: "Account::Transaction",
entryable_attributes: { category_id: categories(:food_and_drink).id }
}
}
end
created_transaction = Account::Transaction.order(created_at: :desc).first
created_entry = Account::Entry.order(created_at: :desc).first
assert_redirected_to account_url(@transaction.account)
assert created_transaction.amount.negative?, "Amount should be negative"
assert_redirected_to account_url(@transaction_entry.account)
assert created_entry.amount.negative?, "Amount should be negative"
end
test "should get paginated index with most recent transactions first" do
get transactions_url
get transactions_url(per_page: 10)
assert_response :success
@recent_transactions.first(10).each do |transaction|
@recent_transaction_entries.first(10).each do |transaction|
assert_dom "#" + dom_id(transaction), count: 1
end
end
test "transaction count represents filtered total" do
get transactions_url
assert_dom "#total-transactions", count: 1, text: @user.family.transactions.select { |t| t.currency == "USD" }.count.to_s
get transactions_url(per_page: 10)
assert_dom "#total-transactions", count: 1, text: @user.family.entries.account_transactions.select { |t| t.currency == "USD" }.count.to_s
new_transaction = @user.family.accounts.first.transactions.create! \
new_transaction = @user.family.accounts.first.entries.create! \
entryable: Account::Transaction.new,
name: "Transaction to search for",
date: Date.current,
amount: 0
amount: 0,
currency: "USD"
get transactions_url(q: { search: new_transaction.name })
@@ -104,28 +112,30 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
end
test "can navigate to paginated result" do
get transactions_url(page: 2)
get transactions_url(page: 2, per_page: 10)
assert_response :success
@recent_transactions[10, 10].select { |t| t.transfer_id == nil }.each do |transaction|
visible_transaction_entries = @recent_transaction_entries[10, 10].reject { |e| e.transfer.present? }
visible_transaction_entries.each do |transaction|
assert_dom "#" + dom_id(transaction), count: 1
end
end
test "loads last page when page is out of range" do
user_oldest_transaction = @user.family.transactions.ordered.reject(&:transfer?).last
user_oldest_transaction_entry = @user.family.entries.account_transactions.chronological.first
get transactions_url(page: 9999999999)
assert_response :success
assert_dom "#" + dom_id(user_oldest_transaction), count: 1
assert_dom "#" + dom_id(user_oldest_transaction_entry), count: 1
end
test "can destroy many transactions at once" do
delete_count = 10
assert_difference("Account::Transaction.count", -delete_count) do
assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do
post bulk_delete_transactions_url, params: {
bulk_delete: {
transaction_ids: @recent_transactions.first(delete_count).pluck(:id)
entry_ids: @recent_transaction_entries.first(delete_count).pluck(:id)
}
}
end
@@ -135,36 +145,41 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
end
test "can update many transactions at once" do
transactions = @user.family.transactions.ordered.limit(20)
transactions = @user.family.entries.account_transactions.reverse_chronological.limit(20)
transactions.each do |transaction|
transaction.update! \
excluded: false,
category_id: Category.first.id,
merchant_id: Merchant.first.id,
notes: "Starting note"
date: Date.current,
entryable_attributes: {
id: transaction.account_transaction.id,
category_id: Category.first.id,
merchant_id: Merchant.first.id,
notes: "Starting note"
}
end
post bulk_update_transactions_url, params: {
bulk_update: {
date: Date.current,
transaction_ids: transactions.map(&:id),
excluded: true,
category_id: Category.second.id,
merchant_id: Merchant.second.id,
notes: "Updated note"
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 0 do
post bulk_update_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,
notes: "Updated note"
}
}
}
end
assert_redirected_to transactions_url
assert_equal "#{transactions.count} transactions updated", flash[:notice]
transactions.reload.each do |transaction|
assert_equal Date.current, transaction.date
assert transaction.excluded
assert_equal Category.second, transaction.category
assert_equal Merchant.second, transaction.merchant
assert_equal "Updated note", transaction.notes
transactions.reload
transactions.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.account_transaction.notes
end
end
end

Some files were not shown because too many files have changed in this diff Show More