mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-18 20:59:39 +02:00
perf(transactions): add kind
to Transaction
model and remove expensive Transfer joins in aggregations (#2388)
* add kind to transaction model * Basic transfer creator * Fix method naming conflict * Creator form pattern * Remove stale methods * Tweak migration * Remove BaseQuery, write entire query in each class for clarity * Query optimizations * Remove unused exchange rate query lines * Remove temporary cache-warming strategy * Fix test * Update transaction search * Decouple transactions endpoint from IncomeStatement * Clean up transactions controller * Update cursor rules * Cleanup comments, logic in search * Fix totals logic on transactions view * Fix pagination * Optimize search totals query * Default to last 30 days on transactions page if no filters * Decouple transactions list from transfer details * Revert transfer route * Migration reset * Bundle update * Fix matching logic, tests * Remove unused code
This commit is contained in:
parent
7aca5a2277
commit
1aae00f586
49 changed files with 1749 additions and 705 deletions
|
@ -66,54 +66,7 @@ All code should maximize readability and simplicity.
|
||||||
- Example 1: be mindful of loading large data payloads in global layouts
|
- Example 1: be mindful of loading large data payloads in global layouts
|
||||||
- Example 2: Avoid N+1 queries
|
- Example 2: Avoid N+1 queries
|
||||||
|
|
||||||
### Convention 5: Use Minitest + Fixtures for testing, minimize fixtures
|
### Convention 5: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
|
||||||
|
|
||||||
Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability.
|
|
||||||
|
|
||||||
- Always use Minitest and fixtures for testing.
|
|
||||||
- Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed.
|
|
||||||
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb)
|
|
||||||
- Take a minimal approach to testing—only test the absolutely critical code paths that will significantly increase developer confidence
|
|
||||||
|
|
||||||
#### Convention 5a: Write minimal, effective tests
|
|
||||||
|
|
||||||
- Use system tests sparingly as they increase the time to complete the test suite
|
|
||||||
- Only write tests for critical and important code paths
|
|
||||||
- Write tests as you go, when required
|
|
||||||
- Take a practical approach to testing. Tests are effective when their presence _significantly increases confidence in the codebase_.
|
|
||||||
|
|
||||||
Below are examples of necessary vs. unnecessary tests:
|
|
||||||
|
|
||||||
```rb
|
|
||||||
# GOOD!!
|
|
||||||
# Necessary test - in this case, we're testing critical domain business logic
|
|
||||||
test "syncs balances" do
|
|
||||||
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
|
|
||||||
|
|
||||||
@account.expects(:start_date).returns(2.days.ago.to_date)
|
|
||||||
|
|
||||||
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
|
||||||
[
|
|
||||||
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
|
||||||
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_difference "@account.balances.count", 2 do
|
|
||||||
Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# BAD!!
|
|
||||||
# Unnecessary test - in this case, this is simply testing ActiveRecord's functionality
|
|
||||||
test "saves balance" do
|
|
||||||
balance_record = Balance.new(balance: 100, currency: "USD")
|
|
||||||
|
|
||||||
assert balance_record.save
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Convention 6: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
|
|
||||||
|
|
||||||
- Enforce `null` checks, unique indexes, and other simple validations in the DB
|
- Enforce `null` checks, unique indexes, and other simple validations in the DB
|
||||||
- ActiveRecord validations _may_ mirror the DB level ones, but not 100% necessary. These are for convenience when error handling in forms. Always prefer client-side form validation when possible.
|
- ActiveRecord validations _may_ mirror the DB level ones, but not 100% necessary. These are for convenience when error handling in forms. Always prefer client-side form validation when possible.
|
||||||
|
|
64
.cursor/rules/stimulus_conventions.mdc
Normal file
64
.cursor/rules/stimulus_conventions.mdc
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
This rule describes how to write Stimulus controllers.
|
||||||
|
|
||||||
|
- **Use declarative actions, not imperative event listeners**
|
||||||
|
- Instead of assigning a Stimulus target and binding it to an event listener in the initializer, always write Controllers + ERB views declaratively by using Stimulus actions in ERB to call methods in the Stimulus JS controller. Below are good vs. bad code.
|
||||||
|
|
||||||
|
BAD code:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// BAD!!!! DO NOT DO THIS!!
|
||||||
|
// Imperative - controller does all the work
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["button", "content"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.buttonTarget.addEventListener("click", this.toggle.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.contentTarget.classList.toggle("hidden")
|
||||||
|
this.buttonTarget.textContent = this.contentTarget.classList.contains("hidden") ? "Show" : "Hide"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
GOOD code:
|
||||||
|
|
||||||
|
```erb
|
||||||
|
<!-- Declarative - HTML declares what happens -->
|
||||||
|
|
||||||
|
<div data-controller="toggle">
|
||||||
|
<button data-action="click->toggle#toggle" data-toggle-target="button">Show</button>
|
||||||
|
<div data-toggle-target="content" class="hidden">Hello World!</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Declarative - controller just responds
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["button", "content"]
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.contentTarget.classList.toggle("hidden")
|
||||||
|
this.buttonTarget.textContent = this.contentTarget.classList.contains("hidden") ? "Show" : "Hide"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Keep Stimulus controllers lightweight and simple**
|
||||||
|
- Always aim for less than 7 controller targets. Any more is a sign of too much complexity.
|
||||||
|
- Use private methods and expose a clear public API
|
||||||
|
|
||||||
|
- **Keep Stimulus controllers focused on what they do best**
|
||||||
|
- Domain logic does NOT belong in a Stimulus controller
|
||||||
|
- Stimulus controllers should aim for a single responsibility, or a group of highly related responsibilities
|
||||||
|
- Make good use of Stimulus's callbacks, actions, targets, values, and classes
|
||||||
|
|
||||||
|
- **Component controllers should not be used outside the component**
|
||||||
|
- If a Stimulus controller is in the app/components directory, it should only be used in its component view. It should not be used anywhere in app/views.
|
||||||
|
|
87
.cursor/rules/testing.mdc
Normal file
87
.cursor/rules/testing.mdc
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
---
|
||||||
|
description:
|
||||||
|
globs: test/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
Use this rule to learn how to write tests for the Maybe codebase.
|
||||||
|
|
||||||
|
Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability.
|
||||||
|
|
||||||
|
- **General testing rules**
|
||||||
|
- Always use Minitest and fixtures for testing, NEVER rspec or factories
|
||||||
|
- Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed.
|
||||||
|
- For tests that require a large number of fixture records to be created, use Rails helpers to help create the records needed for the test, then inline the creation. For example, [entries_test_helper.rb](mdc:test/support/entries_test_helper.rb) provides helpers to easily do this.
|
||||||
|
|
||||||
|
- **Write minimal, effective tests**
|
||||||
|
- Use system tests sparingly as they increase the time to complete the test suite
|
||||||
|
- Only write tests for critical and important code paths
|
||||||
|
- Write tests as you go, when required
|
||||||
|
- Take a practical approach to testing. Tests are effective when their presence _significantly increases confidence in the codebase_.
|
||||||
|
|
||||||
|
Below are examples of necessary vs. unnecessary tests:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
# GOOD!!
|
||||||
|
# Necessary test - in this case, we're testing critical domain business logic
|
||||||
|
test "syncs balances" do
|
||||||
|
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
|
||||||
|
|
||||||
|
@account.expects(:start_date).returns(2.days.ago.to_date)
|
||||||
|
|
||||||
|
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||||
|
[
|
||||||
|
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
||||||
|
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_difference "@account.balances.count", 2 do
|
||||||
|
Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# BAD!!
|
||||||
|
# Unnecessary test - in this case, this is simply testing ActiveRecord's functionality
|
||||||
|
test "saves balance" do
|
||||||
|
balance_record = Balance.new(balance: 100, currency: "USD")
|
||||||
|
|
||||||
|
assert balance_record.save
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Test boundaries correctly**
|
||||||
|
- Distinguish between commands and query methods. Test output of query methods; test that commands were called with the correct params. See an example below:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
class ExampleClass
|
||||||
|
def do_something
|
||||||
|
result = 2 + 2
|
||||||
|
|
||||||
|
CustomEventProcessor.process_result(result)
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class ExampleClass < ActiveSupport::TestCase
|
||||||
|
test "boundaries are tested correctly" do
|
||||||
|
result = ExampleClass.new.do_something
|
||||||
|
|
||||||
|
# GOOD - we're only testing that the command was received, not internal implementation details
|
||||||
|
# The actual tests for CustomEventProcessor belong in a different test suite!
|
||||||
|
CustomEventProcessor.expects(:process_result).with(4).once
|
||||||
|
|
||||||
|
# GOOD - we're testing the implementation of ExampleClass inside its own test suite
|
||||||
|
assert_equal 4, result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
- Never test the implementation details of one class in another classes test suite
|
||||||
|
|
||||||
|
- **Stubs and mocks**
|
||||||
|
- Use `mocha` gem
|
||||||
|
- Always prefer `OpenStruct` when creating mock instances, or in complex cases, a mock class
|
||||||
|
- Only mock what's necessary. If you're not testing return values, don't mock a return value.
|
||||||
|
|
||||||
|
|
100
.cursor/rules/view_conventions.mdc
Normal file
100
.cursor/rules/view_conventions.mdc
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
---
|
||||||
|
description:
|
||||||
|
globs: app/views/**,app/javascript/**,app/components/**/*.js
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
Use this rule to learn how to write ERB views, partials, and Stimulus controllers should be incorporated into them.
|
||||||
|
|
||||||
|
- **Component vs. Partial Decision Making**
|
||||||
|
- **Use ViewComponents when:**
|
||||||
|
- Element has complex logic or styling patterns
|
||||||
|
- Element will be reused across multiple views/contexts
|
||||||
|
- Element needs structured styling with variants/sizes (like buttons, badges)
|
||||||
|
- Element requires interactive behavior or Stimulus controllers
|
||||||
|
- Element has configurable slots or complex APIs
|
||||||
|
- Element needs accessibility features or ARIA support
|
||||||
|
|
||||||
|
- **Use Partials when:**
|
||||||
|
- Element is primarily static HTML with minimal logic
|
||||||
|
- Element is used in only one or few specific contexts
|
||||||
|
- Element is simple template content (like CTAs, static sections)
|
||||||
|
- Element doesn't need variants, sizes, or complex configuration
|
||||||
|
- Element is more about content organization than reusable functionality
|
||||||
|
|
||||||
|
- **Prefer components over partials**
|
||||||
|
- If there is a component available for the use case in app/components, use it
|
||||||
|
- If there is no component, look for a partial
|
||||||
|
- If there is no partial, decide between component or partial based on the criteria above
|
||||||
|
|
||||||
|
- **Examples of Component vs. Partial Usage**
|
||||||
|
```erb
|
||||||
|
<%# Component: Complex, reusable with variants and interactivity %>
|
||||||
|
<%= render DialogComponent.new(variant: :drawer) do |dialog| %>
|
||||||
|
<% dialog.with_header(title: "Account Settings") %>
|
||||||
|
<% dialog.with_body { "Dialog content here" } %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%# Component: Interactive with complex styling options %>
|
||||||
|
<%= render ButtonComponent.new(text: "Save Changes", variant: "primary", confirm: "Are you sure?") %>
|
||||||
|
|
||||||
|
<%# Component: Reusable with variants %>
|
||||||
|
<%= render FilledIconComponent.new(icon: "credit-card", variant: :surface) %>
|
||||||
|
|
||||||
|
<%# Partial: Static template content %>
|
||||||
|
<%= render "shared/logo" %>
|
||||||
|
|
||||||
|
<%# Partial: Simple, context-specific content with basic styling %>
|
||||||
|
<%= render "shared/trend_change", trend: @account.trend, comparison_label: "vs last month" %>
|
||||||
|
|
||||||
|
<%# Partial: Simple divider/utility %>
|
||||||
|
<%= render "shared/ruler", classes: "my-4" %>
|
||||||
|
|
||||||
|
<%# Partial: Simple form utility %>
|
||||||
|
<%= render "shared/form_errors", model: @account %>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Keep domain logic out of the views**
|
||||||
|
```erb
|
||||||
|
<%# BAD!!! %>
|
||||||
|
|
||||||
|
<%# This belongs in the component file, not the template file! %>
|
||||||
|
<% button_classes = { class: "bg-blue-500 hover:bg-blue-600" } %>
|
||||||
|
|
||||||
|
<%= tag.button class: button_classes do %>
|
||||||
|
Save Account
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%# GOOD! %>
|
||||||
|
|
||||||
|
<%= tag.button class: computed_button_classes do %>
|
||||||
|
Save Account
|
||||||
|
<% end %>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Stimulus Integration in Views**
|
||||||
|
- Always use the **declarative approach** when integrating Stimulus controllers
|
||||||
|
- The ERB template should declare what happens, the Stimulus controller should respond
|
||||||
|
- Refer to [stimulus_conventions.mdc](mdc:.cursor/rules/stimulus_conventions.mdc) to learn how to incorporate them into
|
||||||
|
|
||||||
|
GOOD Stimulus controller integration into views:
|
||||||
|
|
||||||
|
```erb
|
||||||
|
<!-- Declarative - HTML declares what happens -->
|
||||||
|
|
||||||
|
<div data-controller="toggle">
|
||||||
|
<button data-action="click->toggle#toggle" data-toggle-target="button">Show</button>
|
||||||
|
<div data-toggle-target="content" class="hidden">Hello World!</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Stimulus Controller Placement Guidelines**
|
||||||
|
- **Component controllers** (in `app/components/`) should only be used within their component templates
|
||||||
|
- **Global controllers** (in `app/javascript/controllers/`) can be used across any view
|
||||||
|
- Pass data from Rails to Stimulus using `data-*-value` attributes, not inline JavaScript
|
||||||
|
- Use Stimulus targets to reference DOM elements, not manual `getElementById` calls
|
||||||
|
|
||||||
|
- **Naming Conventions**
|
||||||
|
- **Components**: Use `ComponentName` suffix (e.g., `ButtonComponent`, `DialogComponent`, `FilledIconComponent`)
|
||||||
|
- **Partials**: Use underscore prefix (e.g., `_trend_change.html.erb`, `_form_errors.html.erb`, `_sync_indicator.html.erb`)
|
||||||
|
- **Shared partials**: Place in `app/views/shared/` directory for reusable content
|
||||||
|
- **Context-specific partials**: Place in relevant controller view directory (e.g., `accounts/_account_sidebar_tabs.html.erb`)
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -98,6 +98,7 @@ node_modules/
|
||||||
tasks.json
|
tasks.json
|
||||||
.taskmaster/tasks/
|
.taskmaster/tasks/
|
||||||
.taskmaster/reports/
|
.taskmaster/reports/
|
||||||
|
.taskmaster/state.json
|
||||||
*.mcp.json
|
*.mcp.json
|
||||||
scripts/
|
scripts/
|
||||||
.cursor/mcp.json
|
.cursor/mcp.json
|
||||||
|
|
|
@ -631,8 +631,8 @@ DEPENDENCIES
|
||||||
climate_control
|
climate_control
|
||||||
csv
|
csv
|
||||||
debug
|
debug
|
||||||
doorkeeper
|
|
||||||
derailed_benchmarks
|
derailed_benchmarks
|
||||||
|
doorkeeper
|
||||||
dotenv-rails
|
dotenv-rails
|
||||||
erb_lint
|
erb_lint
|
||||||
faker
|
faker
|
||||||
|
|
|
@ -1,17 +1,27 @@
|
||||||
class TradesController < ApplicationController
|
class TradesController < ApplicationController
|
||||||
include EntryableResource
|
include EntryableResource
|
||||||
|
|
||||||
|
# Defaults to a buy trade
|
||||||
|
def new
|
||||||
|
@account = Current.family.accounts.find_by(id: params[:account_id])
|
||||||
|
@model = Current.family.entries.new(
|
||||||
|
account: @account,
|
||||||
|
currency: @account ? @account.currency : Current.family.currency,
|
||||||
|
entryable: Trade.new
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Can create a trade, transaction (e.g. "fees"), or transfer (e.g. "withdrawal")
|
||||||
def create
|
def create
|
||||||
@entry = build_entry
|
@account = Current.family.accounts.find(params[:account_id])
|
||||||
|
@model = Trade::CreateForm.new(create_params.merge(account: @account)).create
|
||||||
if @entry.save
|
|
||||||
@entry.sync_account_later
|
|
||||||
|
|
||||||
|
if @model.persisted?
|
||||||
flash[:notice] = t("entries.create.success")
|
flash[:notice] = t("entries.create.success")
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html { redirect_back_or_to account_path(@entry.account) }
|
format.html { redirect_back_or_to account_path(@account) }
|
||||||
format.turbo_stream { stream_redirect_back_or_to account_path(@entry.account) }
|
format.turbo_stream { stream_redirect_back_or_to account_path(@account) }
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
|
@ -41,11 +51,6 @@ class TradesController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def build_entry
|
|
||||||
account = Current.family.accounts.find(params.dig(:entry, :account_id))
|
|
||||||
TradeBuilder.new(create_entry_params.merge(account: account))
|
|
||||||
end
|
|
||||||
|
|
||||||
def entry_params
|
def entry_params
|
||||||
params.require(:entry).permit(
|
params.require(:entry).permit(
|
||||||
:name, :date, :amount, :currency, :excluded, :notes, :nature,
|
:name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||||
|
@ -53,8 +58,8 @@ class TradesController < ApplicationController
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_entry_params
|
def create_params
|
||||||
params.require(:entry).permit(
|
params.require(:model).permit(
|
||||||
:date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id
|
:date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,8 +3,6 @@ class TransactionsController < ApplicationController
|
||||||
|
|
||||||
before_action :store_params!, only: :index
|
before_action :store_params!, only: :index
|
||||||
|
|
||||||
require "digest/md5"
|
|
||||||
|
|
||||||
def new
|
def new
|
||||||
super
|
super
|
||||||
@income_categories = Current.family.categories.incomes.alphabetically
|
@income_categories = Current.family.categories.incomes.alphabetically
|
||||||
|
@ -13,95 +11,22 @@ class TransactionsController < ApplicationController
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@q = search_params
|
@q = search_params
|
||||||
transactions_query = Current.family.transactions.active.search(@q)
|
@search = Transaction::Search.new(Current.family, filters: @q)
|
||||||
|
|
||||||
set_focused_record(transactions_query, params[:focused_record_id], default_per_page: 50)
|
base_scope = @search.transactions_scope
|
||||||
|
.reverse_chronological
|
||||||
|
.includes(
|
||||||
|
{ entry: :account },
|
||||||
|
:category, :merchant, :tags,
|
||||||
|
:transfer_as_inflow, :transfer_as_outflow
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
@pagy, @transactions = pagy(base_scope, limit: per_page, params: ->(p) { p.except(:focused_record_id) })
|
||||||
# Cache the expensive includes & pagination block so the DB work only
|
|
||||||
# runs when either the query params change *or* any entry has been
|
|
||||||
# updated for the current family.
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
latest_update_ts = Current.family.entries.maximum(:updated_at)&.utc&.to_i || 0
|
# No performance penalty by default. Only runs queries if the record is set.
|
||||||
|
if params[:focused_record_id].present?
|
||||||
items_per_page = (params[:per_page].presence || default_params[:per_page]).to_i
|
set_focused_record(base_scope, params[:focused_record_id], default_per_page: per_page)
|
||||||
items_per_page = 1 if items_per_page <= 0
|
|
||||||
|
|
||||||
current_page = (params[:page].presence || default_params[:page]).to_i
|
|
||||||
current_page = 1 if current_page <= 0
|
|
||||||
|
|
||||||
# Build a compact cache digest: sanitized filters + page info + a
|
|
||||||
# token that changes on updates *or* deletions.
|
|
||||||
entries_changed_token = [ latest_update_ts, Current.family.entries.count ].join(":")
|
|
||||||
|
|
||||||
digest_source = {
|
|
||||||
q: @q, # processed & sanitised search params
|
|
||||||
page: current_page, # requested page number
|
|
||||||
per: items_per_page, # page size
|
|
||||||
tok: entries_changed_token
|
|
||||||
}.to_json
|
|
||||||
|
|
||||||
cache_key = Current.family.build_cache_key(
|
|
||||||
"transactions_idx_#{Digest::MD5.hexdigest(digest_source)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
cache_data = Rails.cache.fetch(cache_key, expires_in: 30.minutes) do
|
|
||||||
current_page_i = current_page
|
|
||||||
|
|
||||||
# Initial query
|
|
||||||
offset = (current_page_i - 1) * items_per_page
|
|
||||||
ids = transactions_query
|
|
||||||
.reverse_chronological
|
|
||||||
.limit(items_per_page)
|
|
||||||
.offset(offset)
|
|
||||||
.pluck(:id)
|
|
||||||
|
|
||||||
total_count = transactions_query.count
|
|
||||||
|
|
||||||
if ids.empty? && total_count.positive? && current_page_i > 1
|
|
||||||
current_page_i = (total_count.to_f / items_per_page).ceil
|
|
||||||
offset = (current_page_i - 1) * items_per_page
|
|
||||||
|
|
||||||
ids = transactions_query
|
|
||||||
.reverse_chronological
|
|
||||||
.limit(items_per_page)
|
|
||||||
.offset(offset)
|
|
||||||
.pluck(:id)
|
|
||||||
end
|
|
||||||
|
|
||||||
{ ids: ids, total_count: total_count, current_page: current_page_i }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
ids = cache_data[:ids]
|
|
||||||
total_count = cache_data[:total_count]
|
|
||||||
current_page = cache_data[:current_page]
|
|
||||||
|
|
||||||
# Build Pagy object (this part is cheap – done *after* potential
|
|
||||||
# page fallback so the pagination UI reflects the adjusted page
|
|
||||||
# number).
|
|
||||||
@pagy = Pagy.new(
|
|
||||||
count: total_count,
|
|
||||||
page: current_page,
|
|
||||||
items: items_per_page,
|
|
||||||
params: ->(p) { p.except(:focused_record_id) }
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch the transactions in the cached order
|
|
||||||
@transactions = Current.family.transactions
|
|
||||||
.active
|
|
||||||
.where(id: ids)
|
|
||||||
.includes(
|
|
||||||
{ entry: :account },
|
|
||||||
:category, :merchant, :tags,
|
|
||||||
transfer_as_outflow: { inflow_transaction: { entry: :account } },
|
|
||||||
transfer_as_inflow: { outflow_transaction: { entry: :account } }
|
|
||||||
)
|
|
||||||
|
|
||||||
# Preserve the order defined by `ids`
|
|
||||||
@transactions = ids.map { |id| @transactions.detect { |t| t.id == id } }.compact
|
|
||||||
|
|
||||||
@totals = Current.family.income_statement.totals(transactions_scope: transactions_query)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear_filter
|
def clear_filter
|
||||||
|
@ -124,6 +49,10 @@ class TransactionsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
updated_params["q"] = q_params.presence
|
updated_params["q"] = q_params.presence
|
||||||
|
|
||||||
|
# Add flag to indicate filters were explicitly cleared
|
||||||
|
updated_params["filter_cleared"] = "1" if updated_params["q"].blank?
|
||||||
|
|
||||||
Current.session.update!(prev_transaction_page_params: updated_params)
|
Current.session.update!(prev_transaction_page_params: updated_params)
|
||||||
|
|
||||||
redirect_to transactions_path(updated_params)
|
redirect_to transactions_path(updated_params)
|
||||||
|
@ -185,6 +114,10 @@ class TransactionsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
def per_page
|
||||||
|
params[:per_page].to_i.positive? ? params[:per_page].to_i : 50
|
||||||
|
end
|
||||||
|
|
||||||
def needs_rule_notification?(transaction)
|
def needs_rule_notification?(transaction)
|
||||||
return false if Current.user.rule_prompts_disabled
|
return false if Current.user.rule_prompts_disabled
|
||||||
|
|
||||||
|
@ -217,7 +150,8 @@ class TransactionsController < ApplicationController
|
||||||
cleaned_params = params.fetch(:q, {})
|
cleaned_params = params.fetch(:q, {})
|
||||||
.permit(
|
.permit(
|
||||||
:start_date, :end_date, :search, :amount,
|
:start_date, :end_date, :search, :amount,
|
||||||
:amount_operator, accounts: [], account_ids: [],
|
:amount_operator, :active_accounts_only, :excluded_transactions,
|
||||||
|
accounts: [], account_ids: [],
|
||||||
categories: [], merchants: [], types: [], tags: []
|
categories: [], merchants: [], types: [], tags: []
|
||||||
)
|
)
|
||||||
.to_h
|
.to_h
|
||||||
|
@ -225,35 +159,9 @@ class TransactionsController < ApplicationController
|
||||||
|
|
||||||
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
|
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# Only add default start_date if params are blank AND filters weren't explicitly cleared
|
||||||
# Performance optimisation
|
if cleaned_params.blank? && params[:filter_cleared].blank?
|
||||||
# -------------------------------------------------------------------
|
cleaned_params[:start_date] = 30.days.ago.to_date
|
||||||
# When a user lands on the Transactions page without an explicit date
|
|
||||||
# filter, the previous behaviour queried *all* historical transactions
|
|
||||||
# for the family. For large datasets this results in very expensive
|
|
||||||
# SQL (as shown in Skylight) – particularly the aggregation queries
|
|
||||||
# used for @totals. To keep the UI responsive while still showing a
|
|
||||||
# sensible period of activity, we fall back to the user's preferred
|
|
||||||
# default period (stored on User#default_period, defaulting to
|
|
||||||
# "last_30_days") when **no** date filters have been supplied.
|
|
||||||
#
|
|
||||||
# This effectively changes the default view from "all-time" to a
|
|
||||||
# rolling window, dramatically reducing the rows scanned / grouped in
|
|
||||||
# Postgres without impacting the UX (the user can always clear the
|
|
||||||
# filter).
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
if cleaned_params[:start_date].blank? && cleaned_params[:end_date].blank?
|
|
||||||
period_key = Current.user&.default_period.presence || "last_30_days"
|
|
||||||
|
|
||||||
begin
|
|
||||||
period = Period.from_key(period_key)
|
|
||||||
cleaned_params[:start_date] = period.start_date
|
|
||||||
cleaned_params[:end_date] = period.end_date
|
|
||||||
rescue Period::InvalidKeyError
|
|
||||||
# Fallback – should never happen but keeps things safe.
|
|
||||||
cleaned_params[:start_date] = 30.days.ago.to_date
|
|
||||||
cleaned_params[:end_date] = Date.current
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
cleaned_params
|
cleaned_params
|
||||||
|
@ -263,9 +171,9 @@ class TransactionsController < ApplicationController
|
||||||
if should_restore_params?
|
if should_restore_params?
|
||||||
params_to_restore = {}
|
params_to_restore = {}
|
||||||
|
|
||||||
params_to_restore[:q] = stored_params["q"].presence || default_params[:q]
|
params_to_restore[:q] = stored_params["q"].presence || {}
|
||||||
params_to_restore[:page] = stored_params["page"].presence || default_params[:page]
|
params_to_restore[:page] = stored_params["page"].presence || 1
|
||||||
params_to_restore[:per_page] = stored_params["per_page"].presence || default_params[:per_page]
|
params_to_restore[:per_page] = stored_params["per_page"].presence || 50
|
||||||
|
|
||||||
redirect_to transactions_path(params_to_restore)
|
redirect_to transactions_path(params_to_restore)
|
||||||
else
|
else
|
||||||
|
@ -286,12 +194,4 @@ class TransactionsController < ApplicationController
|
||||||
def stored_params
|
def stored_params
|
||||||
Current.session.prev_transaction_page_params
|
Current.session.prev_transaction_page_params
|
||||||
end
|
end
|
||||||
|
|
||||||
def default_params
|
|
||||||
{
|
|
||||||
q: {},
|
|
||||||
page: 1,
|
|
||||||
per_page: 50
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,7 +8,12 @@ class TransferMatchesController < ApplicationController
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@transfer = build_transfer
|
@transfer = build_transfer
|
||||||
@transfer.save!
|
Transfer.transaction do
|
||||||
|
@transfer.save!
|
||||||
|
@transfer.outflow_transaction.update!(kind: Transfer.kind_for_account(@transfer.outflow_transaction.entry.account))
|
||||||
|
@transfer.inflow_transaction.update!(kind: "funds_movement")
|
||||||
|
end
|
||||||
|
|
||||||
@transfer.sync_account_later
|
@transfer.sync_account_later
|
||||||
|
|
||||||
redirect_back_or_to transactions_path, notice: "Transfer created"
|
redirect_back_or_to transactions_path, notice: "Transfer created"
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
class TransfersController < ApplicationController
|
class TransfersController < ApplicationController
|
||||||
before_action :set_transfer, only: %i[destroy show update]
|
include StreamExtensions
|
||||||
|
|
||||||
|
before_action :set_transfer, only: %i[show destroy update]
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@transfer = Transfer.new
|
@transfer = Transfer.new
|
||||||
|
@ -10,25 +12,19 @@ class TransfersController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
|
@transfer = Transfer::Creator.new(
|
||||||
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
|
family: Current.family,
|
||||||
|
source_account_id: transfer_params[:from_account_id],
|
||||||
@transfer = Transfer.from_accounts(
|
destination_account_id: transfer_params[:to_account_id],
|
||||||
from_account: from_account,
|
|
||||||
to_account: to_account,
|
|
||||||
date: transfer_params[:date],
|
date: transfer_params[:date],
|
||||||
amount: transfer_params[:amount].to_d
|
amount: transfer_params[:amount].to_d
|
||||||
)
|
).create
|
||||||
|
|
||||||
if @transfer.save
|
|
||||||
@transfer.sync_account_later
|
|
||||||
|
|
||||||
flash[:notice] = t(".success")
|
|
||||||
|
|
||||||
|
if @transfer.persisted?
|
||||||
|
success_message = "Transfer created"
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html { redirect_back_or_to transactions_path }
|
format.html { redirect_back_or_to transactions_path, notice: success_message }
|
||||||
redirect_target_url = request.referer || transactions_path
|
format.turbo_stream { stream_redirect_back_or_to transactions_path, notice: success_message }
|
||||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
|
@ -54,9 +50,11 @@ class TransfersController < ApplicationController
|
||||||
|
|
||||||
private
|
private
|
||||||
def set_transfer
|
def set_transfer
|
||||||
@transfer = Transfer.find(params[:id])
|
# Finds the transfer and ensures the family owns it
|
||||||
|
@transfer = Transfer
|
||||||
raise ActiveRecord::RecordNotFound unless @transfer.belongs_to_family?(Current.family)
|
.where(id: params[:id])
|
||||||
|
.where(inflow_transaction_id: Current.family.transactions.select(:id))
|
||||||
|
.first
|
||||||
end
|
end
|
||||||
|
|
||||||
def transfer_params
|
def transfer_params
|
||||||
|
|
|
@ -110,7 +110,13 @@ module ApplicationHelper
|
||||||
|
|
||||||
private
|
private
|
||||||
def calculate_total(item, money_method, negate)
|
def calculate_total(item, money_method, negate)
|
||||||
items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? }
|
# Filter out transfer-type transactions from entries
|
||||||
|
# Only Entry objects have entryable transactions, Account objects don't
|
||||||
|
items = item.reject do |i|
|
||||||
|
i.is_a?(Entry) &&
|
||||||
|
i.entryable.is_a?(Transaction) &&
|
||||||
|
i.entryable.transfer?
|
||||||
|
end
|
||||||
total = items.sum(&money_method)
|
total = items.sum(&money_method)
|
||||||
negate ? -total : total
|
negate ? -total : total
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,6 +11,8 @@ class ImportMarketDataJob < ApplicationJob
|
||||||
queue_as :scheduled
|
queue_as :scheduled
|
||||||
|
|
||||||
def perform(opts)
|
def perform(opts)
|
||||||
|
return if Rails.env.development?
|
||||||
|
|
||||||
opts = opts.symbolize_keys
|
opts = opts.symbolize_keys
|
||||||
mode = opts.fetch(:mode, :full)
|
mode = opts.fetch(:mode, :full)
|
||||||
clear_cache = opts.fetch(:clear_cache, false)
|
clear_cache = opts.fetch(:clear_cache, false)
|
||||||
|
|
|
@ -2,6 +2,8 @@ class SecurityHealthCheckJob < ApplicationJob
|
||||||
queue_as :scheduled
|
queue_as :scheduled
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
|
return if Rails.env.development?
|
||||||
|
|
||||||
Security::HealthChecker.check_all
|
Security::HealthChecker.check_all
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -13,13 +13,6 @@ class Account::Syncer
|
||||||
|
|
||||||
def perform_post_sync
|
def perform_post_sync
|
||||||
account.family.auto_match_transfers!
|
account.family.auto_match_transfers!
|
||||||
|
|
||||||
# Warm IncomeStatement caches so subsequent requests are fast
|
|
||||||
# TODO: this is a temporary solution to speed up pages. Long term we'll throw a materialized view / pre-computed table
|
|
||||||
# in for family stats.
|
|
||||||
income_statement = IncomeStatement.new(account.family)
|
|
||||||
Rails.logger.info("Warming IncomeStatement caches")
|
|
||||||
income_statement.warm_caches!
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -163,7 +163,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
|
||||||
category: txn.category&.name,
|
category: txn.category&.name,
|
||||||
merchant: txn.merchant&.name,
|
merchant: txn.merchant&.name,
|
||||||
tags: txn.tags.map(&:name),
|
tags: txn.tags.map(&:name),
|
||||||
is_transfer: txn.transfer.present?
|
is_transfer: txn.transfer?
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -91,6 +91,7 @@ class Family < ApplicationRecord
|
||||||
entries.order(:date).first&.date || Date.current
|
entries.order(:date).first&.date || Date.current
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Used for invalidating family / balance sheet related aggregation queries
|
||||||
def build_cache_key(key, invalidate_on_data_updates: false)
|
def build_cache_key(key, invalidate_on_data_updates: false)
|
||||||
# Our data sync process updates this timestamp whenever any family account successfully completes a data update.
|
# Our data sync process updates this timestamp whenever any family account successfully completes a data update.
|
||||||
# By including it in the cache key, we can expire caches every time family account data changes.
|
# By including it in the cache key, we can expire caches every time family account data changes.
|
||||||
|
@ -103,6 +104,14 @@ class Family < ApplicationRecord
|
||||||
].compact.join("_")
|
].compact.join("_")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Used for invalidating entry related aggregation queries
|
||||||
|
def entries_cache_version
|
||||||
|
@entries_cache_version ||= begin
|
||||||
|
ts = entries.maximum(:updated_at)
|
||||||
|
ts.present? ? ts.to_i : 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def self_hoster?
|
def self_hoster?
|
||||||
Rails.application.config.app_mode.self_hosted?
|
Rails.application.config.app_mode.self_hosted?
|
||||||
end
|
end
|
||||||
|
|
|
@ -53,6 +53,9 @@ module Family::AutoTransferMatchable
|
||||||
outflow_transaction_id: match.outflow_transaction_id,
|
outflow_transaction_id: match.outflow_transaction_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Transaction.find(match.inflow_transaction_id).update!(kind: "funds_movement")
|
||||||
|
Transaction.find(match.outflow_transaction_id).update!(kind: Transfer.kind_for_account(Transaction.find(match.outflow_transaction_id).entry.account))
|
||||||
|
|
||||||
used_transaction_ids << match.inflow_transaction_id
|
used_transaction_ids << match.inflow_transaction_id
|
||||||
used_transaction_ids << match.outflow_transaction_id
|
used_transaction_ids << match.outflow_transaction_id
|
||||||
end
|
end
|
||||||
|
|
|
@ -20,8 +20,7 @@ class IncomeStatement
|
||||||
ScopeTotals.new(
|
ScopeTotals.new(
|
||||||
transactions_count: result.sum(&:transactions_count),
|
transactions_count: result.sum(&:transactions_count),
|
||||||
income_money: Money.new(total_income, family.currency),
|
income_money: Money.new(total_income, family.currency),
|
||||||
expense_money: Money.new(total_expense, family.currency),
|
expense_money: Money.new(total_expense, family.currency)
|
||||||
missing_exchange_rates?: result.any?(&:missing_exchange_rates?)
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -53,16 +52,9 @@ class IncomeStatement
|
||||||
family_stats(interval: interval).find { |stat| stat.classification == "income" }&.median || 0
|
family_stats(interval: interval).find { |stat| stat.classification == "income" }&.median || 0
|
||||||
end
|
end
|
||||||
|
|
||||||
def warm_caches!(interval: "month")
|
|
||||||
totals
|
|
||||||
family_stats(interval: interval)
|
|
||||||
category_stats(interval: interval)
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money, :missing_exchange_rates?)
|
ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money)
|
||||||
PeriodTotal = Data.define(:classification, :total, :currency, :missing_exchange_rates?, :category_totals)
|
PeriodTotal = Data.define(:classification, :total, :currency, :category_totals)
|
||||||
CategoryTotal = Data.define(:category, :total, :currency, :weight)
|
CategoryTotal = Data.define(:category, :total, :currency, :weight)
|
||||||
|
|
||||||
def categories
|
def categories
|
||||||
|
@ -102,7 +94,6 @@ class IncomeStatement
|
||||||
classification: classification,
|
classification: classification,
|
||||||
total: category_totals.reject { |ct| ct.category.subcategory? }.sum(&:total),
|
total: category_totals.reject { |ct| ct.category.subcategory? }.sum(&:total),
|
||||||
currency: family.currency,
|
currency: family.currency,
|
||||||
missing_exchange_rates?: totals.any?(&:missing_exchange_rates?),
|
|
||||||
category_totals: category_totals
|
category_totals: category_totals
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
@ -110,14 +101,14 @@ class IncomeStatement
|
||||||
def family_stats(interval: "month")
|
def family_stats(interval: "month")
|
||||||
@family_stats ||= {}
|
@family_stats ||= {}
|
||||||
@family_stats[interval] ||= Rails.cache.fetch([
|
@family_stats[interval] ||= Rails.cache.fetch([
|
||||||
"income_statement", "family_stats", family.id, interval, entries_cache_version
|
"income_statement", "family_stats", family.id, interval, family.entries_cache_version
|
||||||
]) { FamilyStats.new(family, interval:).call }
|
]) { FamilyStats.new(family, interval:).call }
|
||||||
end
|
end
|
||||||
|
|
||||||
def category_stats(interval: "month")
|
def category_stats(interval: "month")
|
||||||
@category_stats ||= {}
|
@category_stats ||= {}
|
||||||
@category_stats[interval] ||= Rails.cache.fetch([
|
@category_stats[interval] ||= Rails.cache.fetch([
|
||||||
"income_statement", "category_stats", family.id, interval, entries_cache_version
|
"income_statement", "category_stats", family.id, interval, family.entries_cache_version
|
||||||
]) { CategoryStats.new(family, interval:).call }
|
]) { CategoryStats.new(family, interval:).call }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -125,24 +116,11 @@ class IncomeStatement
|
||||||
sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql)
|
sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql)
|
||||||
|
|
||||||
Rails.cache.fetch([
|
Rails.cache.fetch([
|
||||||
"income_statement", "totals_query", family.id, sql_hash, entries_cache_version
|
"income_statement", "totals_query", family.id, sql_hash, family.entries_cache_version
|
||||||
]) { Totals.new(family, transactions_scope: transactions_scope).call }
|
]) { Totals.new(family, transactions_scope: transactions_scope).call }
|
||||||
end
|
end
|
||||||
|
|
||||||
def monetizable_currency
|
def monetizable_currency
|
||||||
family.currency
|
family.currency
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns a monotonically increasing integer based on the most recent
|
|
||||||
# update to any Entry that belongs to the family. Incorporated into cache
|
|
||||||
# keys so they expire automatically on data changes.
|
|
||||||
def entries_cache_version
|
|
||||||
@entries_cache_version ||= begin
|
|
||||||
ts = Entry.joins(:account)
|
|
||||||
.where(accounts: { family_id: family.id })
|
|
||||||
.maximum(:updated_at)
|
|
||||||
|
|
||||||
ts.present? ? ts.to_i : 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,43 +0,0 @@
|
||||||
module IncomeStatement::BaseQuery
|
|
||||||
private
|
|
||||||
def base_query_sql(family:, interval:, transactions_scope:)
|
|
||||||
sql = <<~SQL
|
|
||||||
SELECT
|
|
||||||
c.id as category_id,
|
|
||||||
c.parent_id as parent_category_id,
|
|
||||||
date_trunc(:interval, ae.date) as date,
|
|
||||||
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
|
||||||
SUM(ae.amount * COALESCE(er.rate, 1)) as total,
|
|
||||||
COUNT(ae.id) as transactions_count,
|
|
||||||
BOOL_OR(ae.currency <> :target_currency AND er.rate IS NULL) as missing_exchange_rates
|
|
||||||
FROM (#{transactions_scope.to_sql}) at
|
|
||||||
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
|
|
||||||
LEFT JOIN categories c ON c.id = at.category_id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT t.*, t.id as transfer_id, a.accountable_type
|
|
||||||
FROM transfers t
|
|
||||||
JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id
|
|
||||||
AND ae.entryable_type = 'Transaction'
|
|
||||||
JOIN accounts a ON a.id = ae.account_id
|
|
||||||
) transfer_info ON (
|
|
||||||
transfer_info.inflow_transaction_id = at.id OR
|
|
||||||
transfer_info.outflow_transaction_id = at.id
|
|
||||||
)
|
|
||||||
LEFT JOIN exchange_rates er ON (
|
|
||||||
er.date = ae.date AND
|
|
||||||
er.from_currency = ae.currency AND
|
|
||||||
er.to_currency = :target_currency
|
|
||||||
)
|
|
||||||
WHERE (
|
|
||||||
transfer_info.transfer_id IS NULL OR
|
|
||||||
(ae.amount > 0 AND transfer_info.accountable_type = 'Loan')
|
|
||||||
)
|
|
||||||
GROUP BY 1, 2, 3, 4
|
|
||||||
SQL
|
|
||||||
|
|
||||||
ActiveRecord::Base.sanitize_sql_array([
|
|
||||||
sql,
|
|
||||||
{ target_currency: family.currency, interval: interval }
|
|
||||||
])
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,40 +1,61 @@
|
||||||
class IncomeStatement::CategoryStats
|
class IncomeStatement::CategoryStats
|
||||||
include IncomeStatement::BaseQuery
|
|
||||||
|
|
||||||
def initialize(family, interval: "month")
|
def initialize(family, interval: "month")
|
||||||
@family = family
|
@family = family
|
||||||
@interval = interval
|
@interval = interval
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
ActiveRecord::Base.connection.select_all(query_sql).map do |row|
|
ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row|
|
||||||
StatRow.new(
|
StatRow.new(
|
||||||
category_id: row["category_id"],
|
category_id: row["category_id"],
|
||||||
classification: row["classification"],
|
classification: row["classification"],
|
||||||
median: row["median"],
|
median: row["median"],
|
||||||
avg: row["avg"],
|
avg: row["avg"]
|
||||||
missing_exchange_rates?: row["missing_exchange_rates"]
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
StatRow = Data.define(:category_id, :classification, :median, :avg, :missing_exchange_rates?)
|
StatRow = Data.define(:category_id, :classification, :median, :avg)
|
||||||
|
|
||||||
|
def sanitized_query_sql
|
||||||
|
ActiveRecord::Base.sanitize_sql_array([
|
||||||
|
query_sql,
|
||||||
|
{
|
||||||
|
target_currency: @family.currency,
|
||||||
|
interval: @interval,
|
||||||
|
family_id: @family.id
|
||||||
|
}
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
def query_sql
|
def query_sql
|
||||||
base_sql = base_query_sql(family: @family, interval: @interval, transactions_scope: @family.transactions.active)
|
|
||||||
|
|
||||||
<<~SQL
|
<<~SQL
|
||||||
WITH base_totals AS (
|
WITH period_totals AS (
|
||||||
#{base_sql}
|
SELECT
|
||||||
|
c.id as category_id,
|
||||||
|
date_trunc(:interval, ae.date) as period,
|
||||||
|
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||||
|
SUM(ae.amount * COALESCE(er.rate, 1)) as total
|
||||||
|
FROM transactions t
|
||||||
|
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction'
|
||||||
|
JOIN accounts a ON a.id = ae.account_id
|
||||||
|
LEFT JOIN categories c ON c.id = t.category_id
|
||||||
|
LEFT JOIN exchange_rates er ON (
|
||||||
|
er.date = ae.date AND
|
||||||
|
er.from_currency = ae.currency AND
|
||||||
|
er.to_currency = :target_currency
|
||||||
|
)
|
||||||
|
WHERE a.family_id = :family_id
|
||||||
|
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||||
|
GROUP BY c.id, period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
category_id,
|
category_id,
|
||||||
classification,
|
classification,
|
||||||
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
|
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
|
||||||
ABS(AVG(total)) as avg,
|
ABS(AVG(total)) as avg
|
||||||
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
|
FROM period_totals
|
||||||
FROM base_totals
|
|
||||||
GROUP BY category_id, classification;
|
GROUP BY category_id, classification;
|
||||||
SQL
|
SQL
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,46 +1,57 @@
|
||||||
class IncomeStatement::FamilyStats
|
class IncomeStatement::FamilyStats
|
||||||
include IncomeStatement::BaseQuery
|
|
||||||
|
|
||||||
def initialize(family, interval: "month")
|
def initialize(family, interval: "month")
|
||||||
@family = family
|
@family = family
|
||||||
@interval = interval
|
@interval = interval
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
ActiveRecord::Base.connection.select_all(query_sql).map do |row|
|
ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row|
|
||||||
StatRow.new(
|
StatRow.new(
|
||||||
classification: row["classification"],
|
classification: row["classification"],
|
||||||
median: row["median"],
|
median: row["median"],
|
||||||
avg: row["avg"],
|
avg: row["avg"]
|
||||||
missing_exchange_rates?: row["missing_exchange_rates"]
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
StatRow = Data.define(:classification, :median, :avg, :missing_exchange_rates?)
|
StatRow = Data.define(:classification, :median, :avg)
|
||||||
|
|
||||||
|
def sanitized_query_sql
|
||||||
|
ActiveRecord::Base.sanitize_sql_array([
|
||||||
|
query_sql,
|
||||||
|
{
|
||||||
|
target_currency: @family.currency,
|
||||||
|
interval: @interval,
|
||||||
|
family_id: @family.id
|
||||||
|
}
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
def query_sql
|
def query_sql
|
||||||
base_sql = base_query_sql(family: @family, interval: @interval, transactions_scope: @family.transactions.active)
|
|
||||||
|
|
||||||
<<~SQL
|
<<~SQL
|
||||||
WITH base_totals AS (
|
WITH period_totals AS (
|
||||||
#{base_sql}
|
|
||||||
), aggregated_totals AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
date,
|
date_trunc(:interval, ae.date) as period,
|
||||||
classification,
|
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||||
SUM(total) as total,
|
SUM(ae.amount * COALESCE(er.rate, 1)) as total
|
||||||
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
|
FROM transactions t
|
||||||
FROM base_totals
|
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction'
|
||||||
GROUP BY date, classification
|
JOIN accounts a ON a.id = ae.account_id
|
||||||
|
LEFT JOIN exchange_rates er ON (
|
||||||
|
er.date = ae.date AND
|
||||||
|
er.from_currency = ae.currency AND
|
||||||
|
er.to_currency = :target_currency
|
||||||
|
)
|
||||||
|
WHERE a.family_id = :family_id
|
||||||
|
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||||
|
GROUP BY period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
classification,
|
classification,
|
||||||
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
|
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
|
||||||
ABS(AVG(total)) as avg,
|
ABS(AVG(total)) as avg
|
||||||
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
|
FROM period_totals
|
||||||
FROM aggregated_totals
|
|
||||||
GROUP BY classification;
|
GROUP BY classification;
|
||||||
SQL
|
SQL
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
class IncomeStatement::Totals
|
class IncomeStatement::Totals
|
||||||
include IncomeStatement::BaseQuery
|
|
||||||
|
|
||||||
def initialize(family, transactions_scope:)
|
def initialize(family, transactions_scope:)
|
||||||
@family = family
|
@family = family
|
||||||
@transactions_scope = transactions_scope
|
@transactions_scope = transactions_scope
|
||||||
|
@ -13,31 +11,47 @@ class IncomeStatement::Totals
|
||||||
category_id: row["category_id"],
|
category_id: row["category_id"],
|
||||||
classification: row["classification"],
|
classification: row["classification"],
|
||||||
total: row["total"],
|
total: row["total"],
|
||||||
transactions_count: row["transactions_count"],
|
transactions_count: row["transactions_count"]
|
||||||
missing_exchange_rates?: row["missing_exchange_rates"]
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count, :missing_exchange_rates?)
|
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count)
|
||||||
|
|
||||||
def query_sql
|
def query_sql
|
||||||
base_sql = base_query_sql(family: @family, interval: "day", transactions_scope: @transactions_scope)
|
ActiveRecord::Base.sanitize_sql_array([
|
||||||
|
optimized_query_sql,
|
||||||
|
sql_params
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
# OPTIMIZED: Direct SUM aggregation without unnecessary time bucketing
|
||||||
|
# Eliminates CTE and intermediate date grouping for maximum performance
|
||||||
|
def optimized_query_sql
|
||||||
<<~SQL
|
<<~SQL
|
||||||
WITH base_totals AS (
|
|
||||||
#{base_sql}
|
|
||||||
)
|
|
||||||
SELECT
|
SELECT
|
||||||
parent_category_id,
|
c.id as category_id,
|
||||||
category_id,
|
c.parent_id as parent_category_id,
|
||||||
classification,
|
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||||
ABS(SUM(total)) as total,
|
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
|
||||||
BOOL_OR(missing_exchange_rates) as missing_exchange_rates,
|
COUNT(ae.id) as transactions_count
|
||||||
SUM(transactions_count) as transactions_count
|
FROM (#{@transactions_scope.to_sql}) at
|
||||||
FROM base_totals
|
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
|
||||||
GROUP BY 1, 2, 3;
|
LEFT JOIN categories c ON c.id = at.category_id
|
||||||
|
LEFT JOIN exchange_rates er ON (
|
||||||
|
er.date = ae.date AND
|
||||||
|
er.from_currency = ae.currency AND
|
||||||
|
er.to_currency = :target_currency
|
||||||
|
)
|
||||||
|
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||||
|
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END;
|
||||||
SQL
|
SQL
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sql_params
|
||||||
|
{
|
||||||
|
target_currency: @family.currency
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
113
app/models/trade/create_form.rb
Normal file
113
app/models/trade/create_form.rb
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
class Trade::CreateForm
|
||||||
|
include ActiveModel::Model
|
||||||
|
|
||||||
|
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||||
|
:price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||||
|
|
||||||
|
# Either creates a trade, transaction, or transfer based on type
|
||||||
|
# Returns the model, regardless of success or failure
|
||||||
|
def create
|
||||||
|
case type
|
||||||
|
when "buy", "sell"
|
||||||
|
create_trade
|
||||||
|
when "interest"
|
||||||
|
create_interest_income
|
||||||
|
when "deposit", "withdrawal"
|
||||||
|
create_transfer
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
# Users can either look up a ticker from our provider (Synth) or enter a manual, "offline" ticker (that we won't fetch prices for)
|
||||||
|
def security
|
||||||
|
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
|
||||||
|
|
||||||
|
Security::Resolver.new(
|
||||||
|
ticker_symbol,
|
||||||
|
exchange_operating_mic: exchange_operating_mic
|
||||||
|
).resolve
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_trade
|
||||||
|
prefix = type == "sell" ? "Sell " : "Buy "
|
||||||
|
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
|
||||||
|
signed_qty = type == "sell" ? -qty.to_d : qty.to_d
|
||||||
|
signed_amount = signed_qty * price.to_d
|
||||||
|
|
||||||
|
trade_entry = account.entries.new(
|
||||||
|
name: trade_name,
|
||||||
|
date: date,
|
||||||
|
amount: signed_amount,
|
||||||
|
currency: currency,
|
||||||
|
entryable: Trade.new(
|
||||||
|
qty: signed_qty,
|
||||||
|
price: price,
|
||||||
|
currency: currency,
|
||||||
|
security: security
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if trade_entry.save
|
||||||
|
trade_entry.lock_saved_attributes!
|
||||||
|
account.sync_later
|
||||||
|
end
|
||||||
|
|
||||||
|
trade_entry
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_interest_income
|
||||||
|
signed_amount = amount.to_d * -1
|
||||||
|
|
||||||
|
entry = account.entries.build(
|
||||||
|
name: "Interest payment",
|
||||||
|
date: date,
|
||||||
|
amount: signed_amount,
|
||||||
|
currency: currency,
|
||||||
|
entryable: Transaction.new
|
||||||
|
)
|
||||||
|
|
||||||
|
if entry.save
|
||||||
|
entry.lock_saved_attributes!
|
||||||
|
account.sync_later
|
||||||
|
end
|
||||||
|
|
||||||
|
entry
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_transfer
|
||||||
|
if transfer_account_id.present?
|
||||||
|
from_account_id = type == "withdrawal" ? account.id : transfer_account_id
|
||||||
|
to_account_id = type == "withdrawal" ? transfer_account_id : account.id
|
||||||
|
|
||||||
|
Transfer::Creator.new(
|
||||||
|
family: account.family,
|
||||||
|
source_account_id: from_account_id,
|
||||||
|
destination_account_id: to_account_id,
|
||||||
|
date: date,
|
||||||
|
amount: amount
|
||||||
|
).create
|
||||||
|
else
|
||||||
|
create_unlinked_transfer
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# If user doesn't provide the reciprocal account, it's a regular transaction
|
||||||
|
def create_unlinked_transfer
|
||||||
|
signed_amount = type == "deposit" ? amount.to_d * -1 : amount.to_d
|
||||||
|
|
||||||
|
entry = account.entries.build(
|
||||||
|
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
|
||||||
|
date: date,
|
||||||
|
amount: signed_amount,
|
||||||
|
currency: currency,
|
||||||
|
entryable: Transaction.new
|
||||||
|
)
|
||||||
|
|
||||||
|
if entry.save
|
||||||
|
entry.lock_saved_attributes!
|
||||||
|
account.sync_later
|
||||||
|
end
|
||||||
|
|
||||||
|
entry
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,137 +0,0 @@
|
||||||
class TradeBuilder
|
|
||||||
include ActiveModel::Model
|
|
||||||
|
|
||||||
attr_accessor :account, :date, :amount, :currency, :qty,
|
|
||||||
:price, :ticker, :manual_ticker, :type, :transfer_account_id
|
|
||||||
|
|
||||||
attr_reader :buildable
|
|
||||||
|
|
||||||
def initialize(attributes = {})
|
|
||||||
super
|
|
||||||
@buildable = set_buildable
|
|
||||||
end
|
|
||||||
|
|
||||||
def save
|
|
||||||
buildable.save
|
|
||||||
end
|
|
||||||
|
|
||||||
def lock_saved_attributes!
|
|
||||||
if buildable.is_a?(Transfer)
|
|
||||||
buildable.inflow_transaction.entry.lock_saved_attributes!
|
|
||||||
buildable.outflow_transaction.entry.lock_saved_attributes!
|
|
||||||
else
|
|
||||||
buildable.lock_saved_attributes!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def entryable
|
|
||||||
return nil if buildable.is_a?(Transfer)
|
|
||||||
|
|
||||||
buildable.entryable
|
|
||||||
end
|
|
||||||
|
|
||||||
def errors
|
|
||||||
buildable.errors
|
|
||||||
end
|
|
||||||
|
|
||||||
def sync_account_later
|
|
||||||
buildable.sync_account_later
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def set_buildable
|
|
||||||
case type
|
|
||||||
when "buy", "sell"
|
|
||||||
build_trade
|
|
||||||
when "deposit", "withdrawal"
|
|
||||||
build_transfer
|
|
||||||
when "interest"
|
|
||||||
build_interest
|
|
||||||
else
|
|
||||||
raise "Unknown trade type: #{type}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_trade
|
|
||||||
prefix = type == "sell" ? "Sell " : "Buy "
|
|
||||||
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
|
|
||||||
|
|
||||||
account.entries.new(
|
|
||||||
name: trade_name,
|
|
||||||
date: date,
|
|
||||||
amount: signed_amount,
|
|
||||||
currency: currency,
|
|
||||||
entryable: Trade.new(
|
|
||||||
qty: signed_qty,
|
|
||||||
price: price,
|
|
||||||
currency: currency,
|
|
||||||
security: security
|
|
||||||
)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_transfer
|
|
||||||
transfer_account = family.accounts.find(transfer_account_id) if transfer_account_id.present?
|
|
||||||
|
|
||||||
if transfer_account
|
|
||||||
from_account = type == "withdrawal" ? account : transfer_account
|
|
||||||
to_account = type == "withdrawal" ? transfer_account : account
|
|
||||||
|
|
||||||
Transfer.from_accounts(
|
|
||||||
from_account: from_account,
|
|
||||||
to_account: to_account,
|
|
||||||
date: date,
|
|
||||||
amount: signed_amount
|
|
||||||
)
|
|
||||||
else
|
|
||||||
account.entries.build(
|
|
||||||
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
|
|
||||||
date: date,
|
|
||||||
amount: signed_amount,
|
|
||||||
currency: currency,
|
|
||||||
entryable: Transaction.new
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_interest
|
|
||||||
account.entries.build(
|
|
||||||
name: "Interest payment",
|
|
||||||
date: date,
|
|
||||||
amount: signed_amount,
|
|
||||||
currency: currency,
|
|
||||||
entryable: Transaction.new
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def signed_qty
|
|
||||||
return nil unless type.in?([ "buy", "sell" ])
|
|
||||||
|
|
||||||
type == "sell" ? -qty.to_d : qty.to_d
|
|
||||||
end
|
|
||||||
|
|
||||||
def signed_amount
|
|
||||||
case type
|
|
||||||
when "buy", "sell"
|
|
||||||
signed_qty * price.to_d
|
|
||||||
when "deposit", "withdrawal"
|
|
||||||
type == "deposit" ? -amount.to_d : amount.to_d
|
|
||||||
when "interest"
|
|
||||||
amount.to_d * -1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def family
|
|
||||||
account.family
|
|
||||||
end
|
|
||||||
|
|
||||||
# Users can either look up a ticker from our provider (Synth) or enter a manual, "offline" ticker (that we won't fetch prices for)
|
|
||||||
def security
|
|
||||||
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
|
|
||||||
|
|
||||||
Security::Resolver.new(
|
|
||||||
ticker_symbol,
|
|
||||||
exchange_operating_mic: exchange_operating_mic
|
|
||||||
).resolve
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -9,10 +9,17 @@ class Transaction < ApplicationRecord
|
||||||
|
|
||||||
accepts_nested_attributes_for :taggings, allow_destroy: true
|
accepts_nested_attributes_for :taggings, allow_destroy: true
|
||||||
|
|
||||||
class << self
|
enum :kind, {
|
||||||
def search(params)
|
standard: "standard", # A regular transaction, included in budget analytics
|
||||||
Search.new(params).build_query(all)
|
funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics
|
||||||
end
|
cc_payment: "cc_payment", # A CC payment, excluded from budget analytics (CC payments offset the sum of expense transactions)
|
||||||
|
loan_payment: "loan_payment", # A payment to a Loan account, treated as an expense in budgets
|
||||||
|
one_time: "one_time" # A one-time expense/income, excluded from budget analytics
|
||||||
|
}
|
||||||
|
|
||||||
|
# Overarching grouping method for all transfer-type transactions
|
||||||
|
def transfer?
|
||||||
|
funds_movement? || cc_payment? || loan_payment?
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_category!(category)
|
def set_category!(category)
|
||||||
|
|
|
@ -13,37 +13,88 @@ class Transaction::Search
|
||||||
attribute :categories, array: true
|
attribute :categories, array: true
|
||||||
attribute :merchants, array: true
|
attribute :merchants, array: true
|
||||||
attribute :tags, array: true
|
attribute :tags, array: true
|
||||||
|
attribute :active_accounts_only, :boolean, default: true
|
||||||
|
attribute :excluded_transactions, :boolean, default: false
|
||||||
|
|
||||||
def build_query(scope)
|
attr_reader :family
|
||||||
query = scope.joins(entry: :account)
|
|
||||||
.joins(transfer_join)
|
|
||||||
|
|
||||||
query = apply_category_filter(query, categories)
|
def initialize(family, filters: {})
|
||||||
query = apply_type_filter(query, types)
|
@family = family
|
||||||
query = apply_merchant_filter(query, merchants)
|
super(filters)
|
||||||
query = apply_tag_filter(query, tags)
|
end
|
||||||
query = EntrySearch.apply_search_filter(query, search)
|
|
||||||
query = EntrySearch.apply_date_filters(query, start_date, end_date)
|
|
||||||
query = EntrySearch.apply_amount_filter(query, amount, amount_operator)
|
|
||||||
query = EntrySearch.apply_accounts_filter(query, accounts, account_ids)
|
|
||||||
|
|
||||||
query
|
def transactions_scope
|
||||||
|
@transactions_scope ||= begin
|
||||||
|
# This already joins entries + accounts. To avoid expensive double-joins, don't join them again (causes full table scan)
|
||||||
|
query = family.transactions
|
||||||
|
|
||||||
|
query = apply_active_accounts_filter(query, active_accounts_only)
|
||||||
|
query = apply_excluded_transactions_filter(query, excluded_transactions)
|
||||||
|
query = apply_category_filter(query, categories)
|
||||||
|
query = apply_type_filter(query, types)
|
||||||
|
query = apply_merchant_filter(query, merchants)
|
||||||
|
query = apply_tag_filter(query, tags)
|
||||||
|
query = EntrySearch.apply_search_filter(query, search)
|
||||||
|
query = EntrySearch.apply_date_filters(query, start_date, end_date)
|
||||||
|
query = EntrySearch.apply_amount_filter(query, amount, amount_operator)
|
||||||
|
query = EntrySearch.apply_accounts_filter(query, accounts, account_ids)
|
||||||
|
|
||||||
|
query
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Computes totals for the specific search
|
||||||
|
def totals
|
||||||
|
@totals ||= begin
|
||||||
|
Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do
|
||||||
|
result = transactions_scope
|
||||||
|
.select(
|
||||||
|
"COALESCE(SUM(CASE WHEN entries.amount >= 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
|
||||||
|
"COALESCE(SUM(CASE WHEN entries.amount < 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
|
||||||
|
"COUNT(entries.id) as transactions_count"
|
||||||
|
)
|
||||||
|
.joins(
|
||||||
|
ActiveRecord::Base.sanitize_sql_array([
|
||||||
|
"LEFT JOIN exchange_rates er ON (er.date = entries.date AND er.from_currency = entries.currency AND er.to_currency = ?)",
|
||||||
|
family.currency
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.take
|
||||||
|
|
||||||
|
Totals.new(
|
||||||
|
count: result.transactions_count.to_i,
|
||||||
|
income_money: Money.new(result.income_total.to_i, family.currency),
|
||||||
|
expense_money: Money.new(result.expense_total.to_i, family.currency)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cache_key_base
|
||||||
|
[
|
||||||
|
family.id,
|
||||||
|
Digest::SHA256.hexdigest(attributes.sort.to_h.to_json), # cached by filters
|
||||||
|
family.entries_cache_version
|
||||||
|
].join("/")
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def transfer_join
|
Totals = Data.define(:count, :income_money, :expense_money)
|
||||||
<<~SQL
|
|
||||||
LEFT JOIN (
|
def apply_active_accounts_filter(query, active_accounts_only_filter)
|
||||||
SELECT t.*, t.id as transfer_id, a.accountable_type
|
if active_accounts_only_filter
|
||||||
FROM transfers t
|
query.where(accounts: { is_active: true })
|
||||||
JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id
|
else
|
||||||
AND ae.entryable_type = 'Transaction'
|
query
|
||||||
JOIN accounts a ON a.id = ae.account_id
|
end
|
||||||
) transfer_info ON (
|
end
|
||||||
transfer_info.inflow_transaction_id = transactions.id OR
|
|
||||||
transfer_info.outflow_transaction_id = transactions.id
|
def apply_excluded_transactions_filter(query, excluded_transactions_filter)
|
||||||
)
|
unless excluded_transactions_filter
|
||||||
SQL
|
query.where(entries: { excluded: false })
|
||||||
|
else
|
||||||
|
query
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def apply_category_filter(query, categories)
|
def apply_category_filter(query, categories)
|
||||||
|
@ -51,7 +102,7 @@ class Transaction::Search
|
||||||
|
|
||||||
query = query.left_joins(:category).where(
|
query = query.left_joins(:category).where(
|
||||||
"categories.name IN (?) OR (
|
"categories.name IN (?) OR (
|
||||||
categories.id IS NULL AND (transfer_info.transfer_id IS NULL OR transfer_info.accountable_type = 'Loan')
|
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment'))
|
||||||
)",
|
)",
|
||||||
categories
|
categories
|
||||||
)
|
)
|
||||||
|
@ -67,7 +118,7 @@ class Transaction::Search
|
||||||
return query unless types.present?
|
return query unless types.present?
|
||||||
return query if types.sort == [ "expense", "income", "transfer" ]
|
return query if types.sort == [ "expense", "income", "transfer" ]
|
||||||
|
|
||||||
transfer_condition = "transfer_info.transfer_id IS NOT NULL"
|
transfer_condition = "transactions.kind IN ('funds_movement', 'cc_payment', 'loan_payment')"
|
||||||
expense_condition = "entries.amount >= 0"
|
expense_condition = "entries.amount >= 0"
|
||||||
income_condition = "entries.amount <= 0"
|
income_condition = "entries.amount <= 0"
|
||||||
|
|
||||||
|
|
|
@ -14,10 +14,6 @@ module Transaction::Transferable
|
||||||
transfer_as_inflow || transfer_as_outflow
|
transfer_as_inflow || transfer_as_outflow
|
||||||
end
|
end
|
||||||
|
|
||||||
def transfer?
|
|
||||||
transfer.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def transfer_match_candidates
|
def transfer_match_candidates
|
||||||
candidates_scope = if self.entry.amount.negative?
|
candidates_scope = if self.entry.amount.negative?
|
||||||
family_matches_scope.where("inflow_candidates.entryable_id = ?", self.id)
|
family_matches_scope.where("inflow_candidates.entryable_id = ?", self.id)
|
||||||
|
|
|
@ -13,34 +13,14 @@ class Transfer < ApplicationRecord
|
||||||
validate :transfer_has_same_family
|
validate :transfer_has_same_family
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def from_accounts(from_account:, to_account:, date:, amount:)
|
def kind_for_account(account)
|
||||||
# Attempt to convert the amount to the to_account's currency.
|
if account.loan?
|
||||||
# If the conversion fails, use the original amount.
|
"loan_payment"
|
||||||
converted_amount = begin
|
elsif account.liability?
|
||||||
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
|
"cc_payment"
|
||||||
rescue Money::ConversionError
|
else
|
||||||
Money.new(amount.abs, from_account.currency)
|
"funds_movement"
|
||||||
end
|
end
|
||||||
|
|
||||||
new(
|
|
||||||
inflow_transaction: Transaction.new(
|
|
||||||
entry: to_account.entries.build(
|
|
||||||
amount: converted_amount.amount.abs * -1,
|
|
||||||
currency: converted_amount.currency.iso_code,
|
|
||||||
date: date,
|
|
||||||
name: "Transfer from #{from_account.name}",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
outflow_transaction: Transaction.new(
|
|
||||||
entry: from_account.entries.build(
|
|
||||||
amount: amount.abs,
|
|
||||||
currency: from_account.currency,
|
|
||||||
date: date,
|
|
||||||
name: "Transfer to #{to_account.name}",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
status: "confirmed"
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -51,19 +31,28 @@ class Transfer < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Once transfer is destroyed, we need to mark the denormalized kind fields on the transactions
|
||||||
|
def destroy!
|
||||||
|
Transfer.transaction do
|
||||||
|
inflow_transaction.update!(kind: "standard")
|
||||||
|
outflow_transaction.update!(kind: "standard")
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def confirm!
|
def confirm!
|
||||||
update!(status: "confirmed")
|
update!(status: "confirmed")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def date
|
||||||
|
inflow_transaction.entry.date
|
||||||
|
end
|
||||||
|
|
||||||
def sync_account_later
|
def sync_account_later
|
||||||
inflow_transaction&.entry&.sync_account_later
|
inflow_transaction&.entry&.sync_account_later
|
||||||
outflow_transaction&.entry&.sync_account_later
|
outflow_transaction&.entry&.sync_account_later
|
||||||
end
|
end
|
||||||
|
|
||||||
def belongs_to_family?(family)
|
|
||||||
family.transactions.include?(inflow_transaction)
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_account
|
def to_account
|
||||||
inflow_transaction&.entry&.account
|
inflow_transaction&.entry&.account
|
||||||
end
|
end
|
||||||
|
@ -89,6 +78,24 @@ class Transfer < ApplicationRecord
|
||||||
to_account&.liability?
|
to_account&.liability?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def loan_payment?
|
||||||
|
outflow_transaction&.kind == "loan_payment"
|
||||||
|
end
|
||||||
|
|
||||||
|
def liability_payment?
|
||||||
|
outflow_transaction&.kind == "cc_payment"
|
||||||
|
end
|
||||||
|
|
||||||
|
def regular_transfer?
|
||||||
|
outflow_transaction&.kind == "funds_movement"
|
||||||
|
end
|
||||||
|
|
||||||
|
def transfer_type
|
||||||
|
return "loan_payment" if loan_payment?
|
||||||
|
return "liability_payment" if liability_payment?
|
||||||
|
"transfer"
|
||||||
|
end
|
||||||
|
|
||||||
def categorizable?
|
def categorizable?
|
||||||
to_account&.accountable_type == "Loan"
|
to_account&.accountable_type == "Loan"
|
||||||
end
|
end
|
||||||
|
|
85
app/models/transfer/creator.rb
Normal file
85
app/models/transfer/creator.rb
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
class Transfer::Creator
|
||||||
|
def initialize(family:, source_account_id:, destination_account_id:, date:, amount:)
|
||||||
|
@family = family
|
||||||
|
@source_account = family.accounts.find(source_account_id) # early throw if not found
|
||||||
|
@destination_account = family.accounts.find(destination_account_id) # early throw if not found
|
||||||
|
@date = date
|
||||||
|
@amount = amount.to_d
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
transfer = Transfer.new(
|
||||||
|
inflow_transaction: inflow_transaction,
|
||||||
|
outflow_transaction: outflow_transaction,
|
||||||
|
status: "confirmed"
|
||||||
|
)
|
||||||
|
|
||||||
|
if transfer.save
|
||||||
|
source_account.sync_later
|
||||||
|
destination_account.sync_later
|
||||||
|
end
|
||||||
|
|
||||||
|
transfer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
attr_reader :family, :source_account, :destination_account, :date, :amount
|
||||||
|
|
||||||
|
def outflow_transaction
|
||||||
|
name = "#{name_prefix} to #{destination_account.name}"
|
||||||
|
|
||||||
|
Transaction.new(
|
||||||
|
kind: outflow_transaction_kind,
|
||||||
|
entry: source_account.entries.build(
|
||||||
|
amount: amount.abs,
|
||||||
|
currency: source_account.currency,
|
||||||
|
date: date,
|
||||||
|
name: name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def inflow_transaction
|
||||||
|
name = "#{name_prefix} from #{source_account.name}"
|
||||||
|
|
||||||
|
Transaction.new(
|
||||||
|
kind: "funds_movement",
|
||||||
|
entry: destination_account.entries.build(
|
||||||
|
amount: inflow_converted_money.amount.abs * -1,
|
||||||
|
currency: destination_account.currency,
|
||||||
|
date: date,
|
||||||
|
name: name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# If destination account has different currency, its transaction should show up as converted
|
||||||
|
# Future improvement: instead of a 1:1 conversion fallback, add a UI/UX flow for missing rates
|
||||||
|
def inflow_converted_money
|
||||||
|
Money.new(amount.abs, source_account.currency)
|
||||||
|
.exchange_to(
|
||||||
|
destination_account.currency,
|
||||||
|
date: date,
|
||||||
|
fallback_rate: 1.0
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# The "expense" side of a transfer is treated different in analytics based on where it goes.
|
||||||
|
def outflow_transaction_kind
|
||||||
|
if destination_account.loan?
|
||||||
|
"loan_payment"
|
||||||
|
elsif destination_account.liability?
|
||||||
|
"cc_payment"
|
||||||
|
else
|
||||||
|
"funds_movement"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def name_prefix
|
||||||
|
if destination_account.liability?
|
||||||
|
"Payment"
|
||||||
|
else
|
||||||
|
"Transfer"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -9,81 +9,81 @@
|
||||||
class="bg-container placeholder:text-sm placeholder:text-secondary font-normal h-10 relative pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
|
class="bg-container placeholder:text-sm placeholder:text-secondary font-normal h-10 relative pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
|
||||||
data-list-filter-target="input"
|
data-list-filter-target="input"
|
||||||
data-action="list-filter#filter">
|
data-action="list-filter#filter">
|
||||||
<%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
|
<%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div data-list-filter-target="list" class="flex flex-col gap-0.5 p-1.5 mt-0.5 mr-2 max-h-64 overflow-y-scroll scrollbar">
|
||||||
<div data-list-filter-target="list" class="flex flex-col gap-0.5 p-1.5 mt-0.5 mr-2 max-h-64 overflow-y-scroll scrollbar">
|
<div class="pb-2 pl-4 mr-2 text-secondary hidden" data-list-filter-target="emptyMessage">
|
||||||
<div class="pb-2 pl-4 mr-2 text-secondary hidden" data-list-filter-target="emptyMessage">
|
<%= t(".no_categories") %>
|
||||||
<%= t(".no_categories") %>
|
</div>
|
||||||
</div>
|
<% if @categories.any? %>
|
||||||
<% if @categories.any? %>
|
<% Category::Group.for(@categories).each do |group| %>
|
||||||
<% Category::Group.for(@categories).each do |group| %>
|
<%= render "category/dropdowns/row", category: group.category %>
|
||||||
<%= render "category/dropdowns/row", category: group.category %>
|
|
||||||
|
|
||||||
<% group.subcategories.each do |category| %>
|
<% group.subcategories.each do |category| %>
|
||||||
<%= render "category/dropdowns/row", category: category %>
|
<%= render "category/dropdowns/row", category: category %>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% else %>
|
||||||
<% else %>
|
<div class="flex justify-center items-center py-12">
|
||||||
<div class="flex justify-center items-center py-12">
|
<div class="text-center flex flex-col items-center max-w-[500px]">
|
||||||
<div class="text-center flex flex-col items-center max-w-[500px]">
|
<p class="text-sm text-secondary font-normal mb-4"><%= t(".empty") %></p>
|
||||||
<p class="text-sm text-secondary font-normal mb-4"><%= t(".empty") %></p>
|
|
||||||
|
|
||||||
<%= render ButtonComponent.new(
|
<%= render ButtonComponent.new(
|
||||||
text: t(".bootstrap"),
|
text: t(".bootstrap"),
|
||||||
variant: "outline",
|
variant: "outline",
|
||||||
href: bootstrap_categories_path,
|
href: bootstrap_categories_path,
|
||||||
method: :post,
|
method: :post,
|
||||||
data: { turbo_frame: :_top }) %>
|
data: { turbo_frame: :_top }) %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<% end %>
|
||||||
<% end %>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= render "shared/ruler", classes: "my-2" %>
|
<%= render "shared/ruler", classes: "my-2" %>
|
||||||
|
|
||||||
<div class="relative p-1.5 w-full">
|
<div class="relative p-1.5 w-full">
|
||||||
<% if @transaction.category %>
|
<% if @transaction.category %>
|
||||||
<%= button_to transaction_path(@transaction.entry),
|
<%= button_to transaction_path(@transaction.entry),
|
||||||
method: :patch,
|
method: :patch,
|
||||||
data: { turbo_frame: dom_id(@transaction.entry) },
|
data: { turbo_frame: dom_id(@transaction.entry) },
|
||||||
params: { entry: { entryable_type: "Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
|
params: { entry: { entryable_type: "Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
|
||||||
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover" do %>
|
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover" do %>
|
||||||
<%= icon("minus") %>
|
<%= icon("minus") %>
|
||||||
|
|
||||||
<%= t(".clear") %>
|
<%= t(".clear") %>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% unless @transaction.transfer? %>
|
<% unless @transaction.transfer? %>
|
||||||
<%= link_to new_transaction_transfer_match_path(@transaction.entry),
|
<%= link_to new_transaction_transfer_match_path(@transaction.entry),
|
||||||
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover",
|
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover",
|
||||||
data: { turbo_frame: "modal" } do %>
|
data: { turbo_frame: "modal" } do %>
|
||||||
<%= icon("refresh-cw") %>
|
<%= icon("refresh-cw") %>
|
||||||
|
|
||||||
<p>Match transfer/payment</p>
|
<p>Match transfer/payment</p>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2">
|
<div class="flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<%= form_with url: transaction_path(@transaction.entry),
|
<%= form_with url: transaction_path(@transaction.entry),
|
||||||
method: :patch,
|
method: :patch,
|
||||||
data: { controller: "auto-submit-form" } do |f| %>
|
data: { controller: "auto-submit-form" } do |f| %>
|
||||||
<%= f.hidden_field "entry[excluded]", value: !@transaction.entry.excluded %>
|
<%= f.hidden_field "entry[excluded]", value: !@transaction.entry.excluded %>
|
||||||
<%= f.check_box "entry[excluded]",
|
<%= f.check_box "entry[excluded]",
|
||||||
checked: @transaction.entry.excluded,
|
checked: @transaction.entry.excluded,
|
||||||
class: "checkbox checkbox--light",
|
class: "checkbox checkbox--light",
|
||||||
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change" } %>
|
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change" } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>One-time <%= @transaction.entry.amount.negative? ? "income" : "expense" %></p>
|
||||||
|
|
||||||
|
<span class="text-orange-500 ml-auto">
|
||||||
|
<%= icon("asterisk", color: "current") %>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>One-time <%= @transaction.entry.amount.negative? ? "income" : "expense" %></p>
|
|
||||||
|
|
||||||
<span class="text-orange-500 ml-auto">
|
|
||||||
<%= icon("asterisk", color: "current") %>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
<%# locals: (entry:) %>
|
<%# locals: (model:, account:) %>
|
||||||
|
|
||||||
<% type = params[:type] || "buy" %>
|
<% type = params[:type] || "buy" %>
|
||||||
|
|
||||||
<%= styled_form_with model: entry, url: trades_path, data: { controller: "trade-form" } do |form| %>
|
<%= styled_form_with url: trades_path(account_id: account&.id), scope: :model, data: { controller: "trade-form" } do |form| %>
|
||||||
|
|
||||||
<%= form.hidden_field :account_id %>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<% if entry.errors.any? %>
|
<% if model.errors.any? %>
|
||||||
<%= render "shared/form_errors", model: entry %>
|
<%= render "shared/form_errors", model: model %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
@ -22,7 +19,7 @@
|
||||||
{ label: t(".type"), selected: type },
|
{ label: t(".type"), selected: type },
|
||||||
{ data: {
|
{ data: {
|
||||||
action: "trade-form#changeType",
|
action: "trade-form#changeType",
|
||||||
trade_form_url_param: new_trade_path(account_id: entry.account&.id || entry.account_id),
|
trade_form_url_param: new_trade_path(account_id: account&.id),
|
||||||
trade_form_key_param: "type",
|
trade_form_key_param: "type",
|
||||||
}} %>
|
}} %>
|
||||||
|
|
||||||
|
@ -41,10 +38,10 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= form.date_field :date, label: true, value: Date.current, required: true %>
|
<%= form.date_field :date, label: true, value: model.date || Date.current, required: true %>
|
||||||
|
|
||||||
<% unless %w[buy sell].include?(type) %>
|
<% unless %w[buy sell].include?(type) %>
|
||||||
<%= form.money_field :amount, label: t(".amount"), required: true %>
|
<%= form.money_field :amount, label: t(".amount"), value: model.amount, required: true %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if %w[deposit withdrawal].include?(type) %>
|
<% if %w[deposit withdrawal].include?(type) %>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<%= render DialogComponent.new do |dialog| %>
|
<%= render DialogComponent.new do |dialog| %>
|
||||||
<% dialog.with_header(title: t(".title")) %>
|
<% dialog.with_header(title: t(".title")) %>
|
||||||
<% dialog.with_body do %>
|
<% dialog.with_body do %>
|
||||||
<%= render "trades/form", entry: @entry %>
|
<%= render "trades/form", model: @model, account: @account %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 bg-container rounded-xl shadow-border-xs md:divide-x divide-y md:divide-y-0 divide-alpha-black-100 theme-dark:divide-alpha-white-200">
|
<div class="grid grid-cols-1 md:grid-cols-3 bg-container rounded-xl shadow-border-xs md:divide-x divide-y md:divide-y-0 divide-alpha-black-100 theme-dark:divide-alpha-white-200">
|
||||||
<div class="p-4 space-y-2">
|
<div class="p-4 space-y-2">
|
||||||
<p class="text-sm text-secondary">Total transactions</p>
|
<p class="text-sm text-secondary">Total transactions</p>
|
||||||
<p class="text-primary font-medium text-xl" id="total-transactions"><%= totals.transactions_count.round(0) %></p>
|
<p class="text-primary font-medium text-xl" id="total-transactions"><%= totals.count.round(0) %></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 space-y-2">
|
<div class="p-4 space-y-2">
|
||||||
<p class="text-sm text-secondary">Income</p>
|
<p class="text-sm text-secondary">Income</p>
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 lg:col-span-6">
|
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 lg:col-span-6">
|
||||||
<%= check_box_tag dom_id(entry, "selection"),
|
<%= check_box_tag dom_id(entry, "selection"),
|
||||||
disabled: transaction.transfer?,
|
disabled: transaction.transfer.present?,
|
||||||
class: "checkbox checkbox--light",
|
class: "checkbox checkbox--light",
|
||||||
data: {
|
data: {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
|
@ -36,15 +36,27 @@
|
||||||
<div class="truncate">
|
<div class="truncate">
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<%= link_to(
|
<% if transaction.transfer? %>
|
||||||
transaction.transfer? ? transaction.transfer.name : entry.name,
|
<%= link_to(
|
||||||
transaction.transfer? ? transfer_path(transaction.transfer) : entry_path(entry),
|
entry.name,
|
||||||
data: {
|
transaction.transfer.present? ? transfer_path(transaction.transfer) : entry_path(entry),
|
||||||
turbo_frame: "drawer",
|
data: {
|
||||||
turbo_prefetch: false
|
turbo_frame: "drawer",
|
||||||
},
|
turbo_prefetch: false
|
||||||
class: "hover:underline"
|
},
|
||||||
) %>
|
class: "hover:underline"
|
||||||
|
) %>
|
||||||
|
<% else %>
|
||||||
|
<%= link_to(
|
||||||
|
entry.name,
|
||||||
|
entry_path(entry),
|
||||||
|
data: {
|
||||||
|
turbo_frame: "drawer",
|
||||||
|
turbo_prefetch: false
|
||||||
|
},
|
||||||
|
class: "hover:underline"
|
||||||
|
) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<% if entry.excluded %>
|
<% if entry.excluded %>
|
||||||
<span class="text-orange-500" title="One-time <%= entry.amount.negative? ? "income" : "expense" %> (excluded from averages)">
|
<span class="text-orange-500" title="One-time <%= entry.amount.negative? ? "income" : "expense" %> (excluded from averages)">
|
||||||
|
@ -52,16 +64,16 @@
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if transaction.transfer? %>
|
<% if transaction.transfer.present? %>
|
||||||
<%= render "transactions/transfer_match", transaction: transaction %>
|
<%= render "transactions/transfer_match", transaction: transaction %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-secondary text-xs font-normal hidden lg:block">
|
<div class="text-secondary text-xs font-normal hidden lg:block">
|
||||||
<% if transaction.transfer? %>
|
<% if transaction.transfer? %>
|
||||||
<%= render "transfers/account_links",
|
<span class="text-secondary">
|
||||||
transfer: transaction.transfer,
|
<%= transaction.loan_payment? ? "Loan Payment" : "Transfer" %> • <%= entry.account.name %>
|
||||||
is_inflow: transaction.transfer_as_inflow.present? %>
|
</span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to entry.account.name,
|
<%= link_to entry.account.name,
|
||||||
account_path(entry.account, tab: "transactions", focused_record_id: entry.id),
|
account_path(entry.account, tab: "transactions", focused_record_id: entry.id),
|
||||||
|
@ -79,7 +91,7 @@
|
||||||
<%= render "transactions/transaction_category", transaction: transaction %>
|
<%= render "transactions/transaction_category", transaction: transaction %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-4 lg:col-span-2 ml-auto text-right">
|
<div class="col-span-4 ml-auto text-right">
|
||||||
<%= content_tag :p,
|
<%= content_tag :p,
|
||||||
transaction.transfer? && view_ctx == "global" ? "+/- #{format_money(entry.amount_money.abs)}" : format_money(-entry.amount_money),
|
transaction.transfer? && view_ctx == "global" ? "+/- #{format_money(entry.amount_money.abs)}" : format_money(-entry.amount_money),
|
||||||
class: ["text-green-600": entry.amount.negative?] %>
|
class: ["text-green-600": entry.amount.negative?] %>
|
||||||
|
@ -89,7 +101,7 @@
|
||||||
<% if balance_trend&.trend %>
|
<% if balance_trend&.trend %>
|
||||||
<%= tag.p format_money(balance_trend.trend.current),
|
<%= tag.p format_money(balance_trend.trend.current),
|
||||||
class: "font-medium text-sm text-primary" %>
|
class: "font-medium text-sm text-primary" %>
|
||||||
<% else %>
|
<% elsif view_ctx != "global" %>
|
||||||
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
|
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -43,7 +43,7 @@
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<%= render "summary", totals: @totals %>
|
<%= render "summary", totals: @search.totals %>
|
||||||
|
|
||||||
<div id="transactions"
|
<div id="transactions"
|
||||||
data-controller="bulk-select"
|
data-controller="bulk-select"
|
||||||
|
|
32
db/migrate/20250616183654_add_kind_to_transactions.rb
Normal file
32
db/migrate/20250616183654_add_kind_to_transactions.rb
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
class AddKindToTransactions < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :transactions, :kind, :string, null: false, default: "standard"
|
||||||
|
add_index :transactions, :kind
|
||||||
|
|
||||||
|
reversible do |dir|
|
||||||
|
dir.up do
|
||||||
|
# Update transaction kinds based on transfer relationships
|
||||||
|
execute <<~SQL
|
||||||
|
UPDATE transactions
|
||||||
|
SET kind = CASE
|
||||||
|
WHEN destination_accounts.accountable_type = 'Loan' AND entries.amount > 0 THEN 'loan_payment'
|
||||||
|
WHEN destination_accounts.accountable_type = 'CreditCard' AND entries.amount > 0 THEN 'cc_payment'
|
||||||
|
ELSE 'funds_movement'
|
||||||
|
END
|
||||||
|
FROM transfers t
|
||||||
|
JOIN entries ON (
|
||||||
|
entries.entryable_id = t.inflow_transaction_id OR
|
||||||
|
entries.entryable_id = t.outflow_transaction_id
|
||||||
|
)
|
||||||
|
LEFT JOIN entries inflow_entries ON (
|
||||||
|
inflow_entries.entryable_id = t.inflow_transaction_id
|
||||||
|
AND inflow_entries.entryable_type = 'Transaction'
|
||||||
|
)
|
||||||
|
LEFT JOIN accounts destination_accounts ON destination_accounts.id = inflow_entries.account_id
|
||||||
|
WHERE transactions.id = entries.entryable_id
|
||||||
|
AND entries.entryable_type = 'Transaction'
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
11
db/schema.rb
generated
11
db/schema.rb
generated
|
@ -30,7 +30,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_18_120703) do
|
||||||
t.decimal "balance", precision: 19, scale: 4
|
t.decimal "balance", precision: 19, scale: 4
|
||||||
t.string "currency"
|
t.string "currency"
|
||||||
t.boolean "is_active", default: true, null: false
|
t.boolean "is_active", default: true, null: false
|
||||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||||
t.uuid "import_id"
|
t.uuid "import_id"
|
||||||
t.uuid "plaid_account_id"
|
t.uuid "plaid_account_id"
|
||||||
t.boolean "scheduled_for_deletion", default: false
|
t.boolean "scheduled_for_deletion", default: false
|
||||||
|
@ -216,12 +216,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_18_120703) do
|
||||||
t.boolean "excluded", default: false
|
t.boolean "excluded", default: false
|
||||||
t.string "plaid_id"
|
t.string "plaid_id"
|
||||||
t.jsonb "locked_attributes", default: {}
|
t.jsonb "locked_attributes", default: {}
|
||||||
t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date"
|
|
||||||
t.index ["account_id"], name: "index_entries_on_account_id"
|
t.index ["account_id"], name: "index_entries_on_account_id"
|
||||||
t.index ["amount"], name: "index_entries_on_amount"
|
|
||||||
t.index ["date"], name: "index_entries_on_date"
|
|
||||||
t.index ["entryable_id", "entryable_type"], name: "index_entries_on_entryable"
|
|
||||||
t.index ["excluded"], name: "index_entries_on_excluded"
|
|
||||||
t.index ["import_id"], name: "index_entries_on_import_id"
|
t.index ["import_id"], name: "index_entries_on_import_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -232,7 +227,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_18_120703) do
|
||||||
t.date "date", null: false
|
t.date "date", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["date", "from_currency", "to_currency"], name: "index_exchange_rates_on_date_and_currencies"
|
|
||||||
t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true
|
t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true
|
||||||
t.index ["from_currency"], name: "index_exchange_rates_on_from_currency"
|
t.index ["from_currency"], name: "index_exchange_rates_on_from_currency"
|
||||||
t.index ["to_currency"], name: "index_exchange_rates_on_to_currency"
|
t.index ["to_currency"], name: "index_exchange_rates_on_to_currency"
|
||||||
|
@ -691,7 +685,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_18_120703) do
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["tag_id"], name: "index_taggings_on_tag_id"
|
t.index ["tag_id"], name: "index_taggings_on_tag_id"
|
||||||
t.index ["taggable_id", "taggable_type"], name: "index_taggings_on_taggable_id_and_type"
|
|
||||||
t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable"
|
t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -734,7 +727,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_18_120703) do
|
||||||
t.uuid "category_id"
|
t.uuid "category_id"
|
||||||
t.uuid "merchant_id"
|
t.uuid "merchant_id"
|
||||||
t.jsonb "locked_attributes", default: {}
|
t.jsonb "locked_attributes", default: {}
|
||||||
|
t.string "kind", default: "standard", null: false
|
||||||
t.index ["category_id"], name: "index_transactions_on_category_id"
|
t.index ["category_id"], name: "index_transactions_on_category_id"
|
||||||
|
t.index ["kind"], name: "index_transactions_on_kind"
|
||||||
t.index ["merchant_id"], name: "index_transactions_on_merchant_id"
|
t.index ["merchant_id"], name: "index_transactions_on_merchant_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,37 @@
|
||||||
# 4. Run locally, find endpoint needed
|
# 4. Run locally, find endpoint needed
|
||||||
# 5. Run an endpoint, example: `ENDPOINT=/budgets/jun-2025/budget_categories/245637cb-129f-4612-b0a8-1de57559372b RAILS_ENV=production BENCHMARKING_ENABLED=true RAILS_LOG_LEVEL=debug rake benchmarking:ips`
|
# 5. Run an endpoint, example: `ENDPOINT=/budgets/jun-2025/budget_categories/245637cb-129f-4612-b0a8-1de57559372b RAILS_ENV=production BENCHMARKING_ENABLED=true RAILS_LOG_LEVEL=debug rake benchmarking:ips`
|
||||||
namespace :benchmarking do
|
namespace :benchmarking do
|
||||||
|
desc "Benchmark specific code"
|
||||||
|
task code: :environment do
|
||||||
|
Benchmark.ips do |x|
|
||||||
|
x.config(time: 30, warmup: 10)
|
||||||
|
|
||||||
|
family = User.find_by(email: "user@maybe.local").family
|
||||||
|
scope = family.transactions.active
|
||||||
|
|
||||||
|
# x.report("IncomeStatement::Totals") do
|
||||||
|
# IncomeStatement::Totals.new(family, transactions_scope: scope).call
|
||||||
|
# end
|
||||||
|
|
||||||
|
# x.report("IncomeStatement::CategoryStats") do
|
||||||
|
# IncomeStatement::CategoryStats.new(family).call
|
||||||
|
# end
|
||||||
|
|
||||||
|
# x.report("IncomeStatement::FamilyStats") do
|
||||||
|
# IncomeStatement::FamilyStats.new(family).call
|
||||||
|
# end
|
||||||
|
|
||||||
|
puts family.entries.count
|
||||||
|
|
||||||
|
x.report("Transaction::Totals") do
|
||||||
|
search = Transaction::Search.new(family)
|
||||||
|
search.totals
|
||||||
|
end
|
||||||
|
|
||||||
|
x.compare!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
desc "Shorthand task for running warm/cold benchmark"
|
desc "Shorthand task for running warm/cold benchmark"
|
||||||
task endpoint: :environment do
|
task endpoint: :environment do
|
||||||
system(
|
system(
|
||||||
|
|
|
@ -313,13 +313,13 @@ end
|
||||||
accountable: Depository.new
|
accountable: Depository.new
|
||||||
)
|
)
|
||||||
|
|
||||||
transfer = Transfer.from_accounts(
|
transfer = Transfer::Creator.new(
|
||||||
from_account: from_account,
|
family: @family,
|
||||||
to_account: to_account,
|
source_account_id: from_account.id,
|
||||||
|
destination_account_id: to_account.id,
|
||||||
date: Date.current,
|
date: Date.current,
|
||||||
amount: 100
|
amount: 100
|
||||||
)
|
).create
|
||||||
transfer.save!
|
|
||||||
|
|
||||||
get api_v1_transaction_url(transfer.inflow_transaction), headers: api_headers(@api_key)
|
get api_v1_transaction_url(transfer.inflow_transaction), headers: api_headers(@api_key)
|
||||||
assert_response :success
|
assert_response :success
|
||||||
|
|
|
@ -39,9 +39,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_difference -> { Entry.count } => 2,
|
assert_difference -> { Entry.count } => 2,
|
||||||
-> { Transaction.count } => 2,
|
-> { Transaction.count } => 2,
|
||||||
-> { Transfer.count } => 1 do
|
-> { Transfer.count } => 1 do
|
||||||
post trades_url, params: {
|
post trades_url(account_id: @entry.account_id), params: {
|
||||||
entry: {
|
model: {
|
||||||
account_id: @entry.account_id,
|
|
||||||
type: "deposit",
|
type: "deposit",
|
||||||
date: Date.current,
|
date: Date.current,
|
||||||
amount: 10,
|
amount: 10,
|
||||||
|
@ -60,9 +59,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_difference -> { Entry.count } => 2,
|
assert_difference -> { Entry.count } => 2,
|
||||||
-> { Transaction.count } => 2,
|
-> { Transaction.count } => 2,
|
||||||
-> { Transfer.count } => 1 do
|
-> { Transfer.count } => 1 do
|
||||||
post trades_url, params: {
|
post trades_url(account_id: @entry.account_id), params: {
|
||||||
entry: {
|
model: {
|
||||||
account_id: @entry.account_id,
|
|
||||||
type: "withdrawal",
|
type: "withdrawal",
|
||||||
date: Date.current,
|
date: Date.current,
|
||||||
amount: 10,
|
amount: 10,
|
||||||
|
@ -79,9 +77,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_difference -> { Entry.count } => 1,
|
assert_difference -> { Entry.count } => 1,
|
||||||
-> { Transaction.count } => 1,
|
-> { Transaction.count } => 1,
|
||||||
-> { Transfer.count } => 0 do
|
-> { Transfer.count } => 0 do
|
||||||
post trades_url, params: {
|
post trades_url(account_id: @entry.account_id), params: {
|
||||||
entry: {
|
model: {
|
||||||
account_id: @entry.account_id,
|
|
||||||
type: "withdrawal",
|
type: "withdrawal",
|
||||||
date: Date.current,
|
date: Date.current,
|
||||||
amount: 10,
|
amount: 10,
|
||||||
|
@ -98,9 +95,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
|
||||||
test "creates interest entry" do
|
test "creates interest entry" do
|
||||||
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
|
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
|
||||||
post trades_url, params: {
|
post trades_url(account_id: @entry.account_id), params: {
|
||||||
entry: {
|
model: {
|
||||||
account_id: @entry.account_id,
|
|
||||||
type: "interest",
|
type: "interest",
|
||||||
date: Date.current,
|
date: Date.current,
|
||||||
amount: 10,
|
amount: 10,
|
||||||
|
@ -117,9 +113,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
|
||||||
test "creates trade buy entry" do
|
test "creates trade buy entry" do
|
||||||
assert_difference [ "Entry.count", "Trade.count", "Security.count" ], 1 do
|
assert_difference [ "Entry.count", "Trade.count", "Security.count" ], 1 do
|
||||||
post trades_url, params: {
|
post trades_url(account_id: @entry.account_id), params: {
|
||||||
entry: {
|
model: {
|
||||||
account_id: @entry.account_id,
|
|
||||||
type: "buy",
|
type: "buy",
|
||||||
date: Date.current,
|
date: Date.current,
|
||||||
ticker: "NVDA (NASDAQ)",
|
ticker: "NVDA (NASDAQ)",
|
||||||
|
@ -141,9 +136,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
|
||||||
test "creates trade sell entry" do
|
test "creates trade sell entry" do
|
||||||
assert_difference [ "Entry.count", "Trade.count" ], 1 do
|
assert_difference [ "Entry.count", "Trade.count" ], 1 do
|
||||||
post trades_url, params: {
|
post trades_url(account_id: @entry.account_id), params: {
|
||||||
entry: {
|
model: {
|
||||||
account_id: @entry.account_id,
|
|
||||||
type: "sell",
|
type: "sell",
|
||||||
ticker: "AAPL (NYSE)",
|
ticker: "AAPL (NYSE)",
|
||||||
date: Date.current,
|
date: Date.current,
|
||||||
|
|
|
@ -97,31 +97,98 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can paginate" do
|
test "can paginate" do
|
||||||
|
family = families(:empty)
|
||||||
|
sign_in users(:empty)
|
||||||
|
|
||||||
|
# Clean up any existing entries to ensure clean test
|
||||||
|
family.accounts.each { |account| account.entries.delete_all }
|
||||||
|
|
||||||
|
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
||||||
|
|
||||||
|
# Create multiple transactions for pagination
|
||||||
|
25.times do |i|
|
||||||
|
create_transaction(
|
||||||
|
account: account,
|
||||||
|
name: "Transaction #{i + 1}",
|
||||||
|
amount: 100 + i, # Different amounts to prevent transfer matching
|
||||||
|
date: Date.current - i.days # Different dates
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
total_transactions = family.entries.transactions.count
|
||||||
|
assert_operator total_transactions, :>=, 20, "Should have at least 20 transactions for testing"
|
||||||
|
|
||||||
|
# Test page 1 - should show limited transactions
|
||||||
|
get transactions_url(page: 1, per_page: 10)
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
page_1_count = css_select("turbo-frame[id^='entry_']").count
|
||||||
|
assert_equal 10, page_1_count, "Page 1 should respect per_page limit"
|
||||||
|
|
||||||
|
# Test page 2 - should show different transactions
|
||||||
|
get transactions_url(page: 2, per_page: 10)
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
page_2_count = css_select("turbo-frame[id^='entry_']").count
|
||||||
|
assert_operator page_2_count, :>, 0, "Page 2 should show some transactions"
|
||||||
|
assert_operator page_2_count, :<=, 10, "Page 2 should not exceed per_page limit"
|
||||||
|
|
||||||
|
# Test Pagy overflow handling - should redirect or handle gracefully
|
||||||
|
get transactions_url(page: 9999999, per_page: 10)
|
||||||
|
|
||||||
|
# Either success (if Pagy shows last page) or redirect (if Pagy redirects)
|
||||||
|
assert_includes [ 200, 302 ], response.status, "Pagy should handle overflow gracefully"
|
||||||
|
|
||||||
|
if response.status == 302
|
||||||
|
follow_redirect!
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
overflow_count = css_select("turbo-frame[id^='entry_']").count
|
||||||
|
assert_operator overflow_count, :>, 0, "Overflow should show some transactions"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calls Transaction::Search totals method with correct search parameters" do
|
||||||
family = families(:empty)
|
family = families(:empty)
|
||||||
sign_in users(:empty)
|
sign_in users(:empty)
|
||||||
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
||||||
|
|
||||||
11.times do
|
create_transaction(account: account, amount: 100)
|
||||||
create_transaction(account: account)
|
|
||||||
end
|
|
||||||
|
|
||||||
sorted_transactions = family.entries.transactions.reverse_chronological.to_a
|
search = Transaction::Search.new(family)
|
||||||
|
totals = OpenStruct.new(
|
||||||
|
count: 1,
|
||||||
|
expense_money: Money.new(10000, "USD"),
|
||||||
|
income_money: Money.new(0, "USD")
|
||||||
|
)
|
||||||
|
|
||||||
assert_equal 11, sorted_transactions.count
|
expected_filters = { "start_date" => 30.days.ago.to_date }
|
||||||
|
Transaction::Search.expects(:new).with(family, filters: expected_filters).returns(search)
|
||||||
get transactions_url(page: 1, per_page: 10)
|
search.expects(:totals).once.returns(totals)
|
||||||
|
|
||||||
|
get transactions_url
|
||||||
assert_response :success
|
assert_response :success
|
||||||
sorted_transactions.first(10).each do |transaction|
|
end
|
||||||
assert_dom "#" + dom_id(transaction), count: 1
|
|
||||||
end
|
|
||||||
|
|
||||||
get transactions_url(page: 2, per_page: 10)
|
test "calls Transaction::Search totals method with filtered search parameters" do
|
||||||
|
family = families(:empty)
|
||||||
|
sign_in users(:empty)
|
||||||
|
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
||||||
|
category = family.categories.create! name: "Food", color: "#ff0000"
|
||||||
|
|
||||||
assert_dom "#" + dom_id(sorted_transactions.last), count: 1
|
create_transaction(account: account, amount: 100, category: category)
|
||||||
|
|
||||||
get transactions_url(page: 9999999, per_page: 10) # out of range loads last page
|
search = Transaction::Search.new(family, filters: { "categories" => [ "Food" ], "types" => [ "expense" ] })
|
||||||
|
totals = OpenStruct.new(
|
||||||
|
count: 1,
|
||||||
|
expense_money: Money.new(10000, "USD"),
|
||||||
|
income_money: Money.new(0, "USD")
|
||||||
|
)
|
||||||
|
|
||||||
assert_dom "#" + dom_id(sorted_transactions.last), count: 1
|
Transaction::Search.expects(:new).with(family, filters: { "categories" => [ "Food" ], "types" => [ "expense" ] }).returns(search)
|
||||||
|
search.expects(:totals).once.returns(totals)
|
||||||
|
|
||||||
|
get transactions_url(q: { categories: [ "Food" ], types: [ "expense" ] })
|
||||||
|
assert_response :success
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
6
test/fixtures/transactions.yml
vendored
6
test/fixtures/transactions.yml
vendored
|
@ -2,5 +2,7 @@ one:
|
||||||
category: food_and_drink
|
category: food_and_drink
|
||||||
merchant: amazon
|
merchant: amazon
|
||||||
|
|
||||||
transfer_out: { }
|
transfer_out:
|
||||||
transfer_in: { }
|
kind: payment
|
||||||
|
transfer_in:
|
||||||
|
kind: transfer
|
|
@ -12,6 +12,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
@checking_account = @family.accounts.create! name: "Checking", currency: @family.currency, balance: 5000, accountable: Depository.new
|
@checking_account = @family.accounts.create! name: "Checking", currency: @family.currency, balance: 5000, accountable: Depository.new
|
||||||
@credit_card_account = @family.accounts.create! name: "Credit Card", currency: @family.currency, balance: 1000, accountable: CreditCard.new
|
@credit_card_account = @family.accounts.create! name: "Credit Card", currency: @family.currency, balance: 1000, accountable: CreditCard.new
|
||||||
|
@loan_account = @family.accounts.create! name: "Mortgage", currency: @family.currency, balance: 50000, accountable: Loan.new
|
||||||
|
|
||||||
create_transaction(account: @checking_account, amount: -1000, category: @income_category)
|
create_transaction(account: @checking_account, amount: -1000, category: @income_category)
|
||||||
create_transaction(account: @checking_account, amount: 200, category: @groceries_category)
|
create_transaction(account: @checking_account, amount: 200, category: @groceries_category)
|
||||||
|
@ -56,4 +57,217 @@ class IncomeStatementTest < ActiveSupport::TestCase
|
||||||
income_statement = IncomeStatement.new(@family)
|
income_statement = IncomeStatement.new(@family)
|
||||||
assert_equal 1000, income_statement.income_totals(period: Period.last_30_days).total
|
assert_equal 1000, income_statement.income_totals(period: Period.last_30_days).total
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# NEW TESTS: Statistical Methods
|
||||||
|
test "calculates median expense correctly with known dataset" do
|
||||||
|
# Clear existing transactions by deleting entries
|
||||||
|
Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all
|
||||||
|
|
||||||
|
# Create expenses: 100, 200, 300, 400, 500 (median should be 300)
|
||||||
|
create_transaction(account: @checking_account, amount: 100, category: @groceries_category)
|
||||||
|
create_transaction(account: @checking_account, amount: 200, category: @groceries_category)
|
||||||
|
create_transaction(account: @checking_account, amount: 300, category: @groceries_category)
|
||||||
|
create_transaction(account: @checking_account, amount: 400, category: @groceries_category)
|
||||||
|
create_transaction(account: @checking_account, amount: 500, category: @groceries_category)
|
||||||
|
|
||||||
|
income_statement = IncomeStatement.new(@family)
|
||||||
|
# CORRECT BUSINESS LOGIC: Calculates median of time-period totals for budget planning
|
||||||
|
# All transactions in same month = monthly total of 1500, so median = 1500.0
|
||||||
|
assert_equal 1500.0, income_statement.median_expense(interval: "month")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calculates median income correctly with known dataset" do
|
||||||
|
# Clear existing transactions by deleting entries
|
||||||
|
Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all
|
||||||
|
|
||||||
|
# Create income: -200, -300, -400, -500, -600 (median should be -400, displayed as 400)
|
||||||
|
create_transaction(account: @checking_account, amount: -200, category: @income_category)
|
||||||
|
create_transaction(account: @checking_account, amount: -300, category: @income_category)
|
||||||
|
create_transaction(account: @checking_account, amount: -400, category: @income_category)
|
||||||
|
create_transaction(account: @checking_account, amount: -500, category: @income_category)
|
||||||
|
create_transaction(account: @checking_account, amount: -600, category: @income_category)
|
||||||
|
|
||||||
|
income_statement = IncomeStatement.new(@family)
|
||||||
|
# CORRECT BUSINESS LOGIC: Calculates median of time-period totals for budget planning
|
||||||
|
# All transactions in same month = monthly total of -2000, so median = 2000.0
|
||||||
|
assert_equal 2000.0, income_statement.median_income(interval: "month")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calculates average expense correctly with known dataset" do
|
||||||
|
# Clear existing transactions by deleting entries
|
||||||
|
Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all
|
||||||
|
|
||||||
|
# Create expenses: 100, 200, 300 (average should be 200)
|
||||||
|
create_transaction(account: @checking_account, amount: 100, category: @groceries_category)
|
||||||
|
create_transaction(account: @checking_account, amount: 200, category: @groceries_category)
|
||||||
|
create_transaction(account: @checking_account, amount: 300, category: @groceries_category)
|
||||||
|
|
||||||
|
income_statement = IncomeStatement.new(@family)
|
||||||
|
# CORRECT BUSINESS LOGIC: Calculates average of time-period totals for budget planning
|
||||||
|
# All transactions in same month = monthly total of 600, so average = 600.0
|
||||||
|
assert_equal 600.0, income_statement.avg_expense(interval: "month")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calculates category-specific median expense" do
|
||||||
|
# Clear existing transactions by deleting entries
|
||||||
|
Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all
|
||||||
|
|
||||||
|
# Create different amounts for groceries vs other food
|
||||||
|
other_food_category = @family.categories.create! name: "Restaurants", classification: "expense", parent: @food_category
|
||||||
|
|
||||||
|
# Groceries: 100, 300, 500 (median = 300)
|
||||||
|
create_transaction(account: @checking_account, amount: 100, category: @groceries_category)
|
||||||
|
create_transaction(account: @checking_account, amount: 300, category: @groceries_category)
|
||||||
|
create_transaction(account: @checking_account, amount: 500, category: @groceries_category)
|
||||||
|
|
||||||
|
# Restaurants: 50, 150 (median = 100)
|
||||||
|
create_transaction(account: @checking_account, amount: 50, category: other_food_category)
|
||||||
|
create_transaction(account: @checking_account, amount: 150, category: other_food_category)
|
||||||
|
|
||||||
|
income_statement = IncomeStatement.new(@family)
|
||||||
|
# CORRECT BUSINESS LOGIC: Calculates median of time-period totals for budget planning
|
||||||
|
# All groceries in same month = monthly total of 900, so median = 900.0
|
||||||
|
assert_equal 900.0, income_statement.median_expense(interval: "month", category: @groceries_category)
|
||||||
|
# For restaurants: monthly total = 200, so median = 200.0
|
||||||
|
restaurants_median = income_statement.median_expense(interval: "month", category: other_food_category)
|
||||||
|
assert_equal 200.0, restaurants_median
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calculates category-specific average expense" do
|
||||||
|
# Clear existing transactions by deleting entries
|
||||||
|
Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all
|
||||||
|
|
||||||
|
# Create different amounts for groceries
|
||||||
|
# Groceries: 100, 200, 300 (average = 200)
|
||||||
|
create_transaction(account: @checking_account, amount: 100, category: @groceries_category)
|
||||||
|
create_transaction(account: @checking_account, amount: 200, category: @groceries_category)
|
||||||
|
create_transaction(account: @checking_account, amount: 300, category: @groceries_category)
|
||||||
|
|
||||||
|
income_statement = IncomeStatement.new(@family)
|
||||||
|
# CORRECT BUSINESS LOGIC: Calculates average of time-period totals for budget planning
|
||||||
|
# All transactions in same month = monthly total of 600, so average = 600.0
|
||||||
|
assert_equal 600.0, income_statement.avg_expense(interval: "month", category: @groceries_category)
|
||||||
|
end
|
||||||
|
|
||||||
|
# NEW TESTS: Transfer and Kind Filtering
|
||||||
|
# NOTE: These tests now pass because kind filtering is working after the refactoring!
|
||||||
|
test "excludes regular transfers from income statement calculations" do
|
||||||
|
# Create a regular transfer between accounts
|
||||||
|
outflow_transaction = create_transaction(account: @checking_account, amount: 500, kind: "funds_movement")
|
||||||
|
inflow_transaction = create_transaction(account: @credit_card_account, amount: -500, kind: "funds_movement")
|
||||||
|
|
||||||
|
income_statement = IncomeStatement.new(@family)
|
||||||
|
totals = income_statement.totals
|
||||||
|
|
||||||
|
# NOW WORKING: Excludes transfers correctly after refactoring
|
||||||
|
assert_equal 4, totals.transactions_count # Only original 4 transactions
|
||||||
|
assert_equal Money.new(1000, @family.currency), totals.income_money
|
||||||
|
assert_equal Money.new(900, @family.currency), totals.expense_money
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes loan payments as expenses in income statement" do
|
||||||
|
# Create a loan payment transaction
|
||||||
|
loan_payment = create_transaction(account: @checking_account, amount: 1000, category: nil, kind: "loan_payment")
|
||||||
|
|
||||||
|
income_statement = IncomeStatement.new(@family)
|
||||||
|
totals = income_statement.totals
|
||||||
|
|
||||||
|
# CONTINUES TO WORK: Includes loan payments as expenses (loan_payment not in exclusion list)
|
||||||
|
assert_equal 5, totals.transactions_count
|
||||||
|
assert_equal Money.new(1000, @family.currency), totals.income_money
|
||||||
|
assert_equal Money.new(1900, @family.currency), totals.expense_money # 900 + 1000
|
||||||
|
end
|
||||||
|
|
||||||
|
test "excludes one-time transactions from income statement calculations" do
|
||||||
|
# Create a one-time transaction
|
||||||
|
one_time_transaction = create_transaction(account: @checking_account, amount: 250, category: @groceries_category, kind: "one_time")
|
||||||
|
|
||||||
|
income_statement = IncomeStatement.new(@family)
|
||||||
|
totals = income_statement.totals
|
||||||
|
|
||||||
|
# NOW WORKING: Excludes one-time transactions correctly after refactoring
|
||||||
|
assert_equal 4, totals.transactions_count # Only original 4 transactions
|
||||||
|
assert_equal Money.new(1000, @family.currency), totals.income_money
|
||||||
|
assert_equal Money.new(900, @family.currency), totals.expense_money
|
||||||
|
end
|
||||||
|
|
||||||
|
test "excludes payment transactions from income statement calculations" do
|
||||||
|
# Create a payment transaction (credit card payment)
|
||||||
|
payment_transaction = create_transaction(account: @checking_account, amount: 300, category: nil, kind: "cc_payment")
|
||||||
|
|
||||||
|
income_statement = IncomeStatement.new(@family)
|
||||||
|
totals = income_statement.totals
|
||||||
|
|
||||||
|
# NOW WORKING: Excludes payment transactions correctly after refactoring
|
||||||
|
assert_equal 4, totals.transactions_count # Only original 4 transactions
|
||||||
|
assert_equal Money.new(1000, @family.currency), totals.income_money
|
||||||
|
assert_equal Money.new(900, @family.currency), totals.expense_money
|
||||||
|
end
|
||||||
|
|
||||||
|
# NEW TESTS: Interval-Based Calculations
|
||||||
|
test "different intervals return different statistical results with multi-period data" do
|
||||||
|
# Clear existing transactions
|
||||||
|
Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all
|
||||||
|
|
||||||
|
# Create transactions across multiple weeks to test interval behavior
|
||||||
|
# Week 1: 100, 200 (total: 300, median: 150)
|
||||||
|
create_transaction(account: @checking_account, amount: 100, category: @groceries_category, date: 3.weeks.ago)
|
||||||
|
create_transaction(account: @checking_account, amount: 200, category: @groceries_category, date: 3.weeks.ago + 1.day)
|
||||||
|
|
||||||
|
# Week 2: 400, 600 (total: 1000, median: 500)
|
||||||
|
create_transaction(account: @checking_account, amount: 400, category: @groceries_category, date: 2.weeks.ago)
|
||||||
|
create_transaction(account: @checking_account, amount: 600, category: @groceries_category, date: 2.weeks.ago + 1.day)
|
||||||
|
|
||||||
|
# Week 3: 800 (total: 800, median: 800)
|
||||||
|
create_transaction(account: @checking_account, amount: 800, category: @groceries_category, date: 1.week.ago)
|
||||||
|
|
||||||
|
income_statement = IncomeStatement.new(@family)
|
||||||
|
|
||||||
|
month_median = income_statement.median_expense(interval: "month")
|
||||||
|
week_median = income_statement.median_expense(interval: "week")
|
||||||
|
|
||||||
|
# CRITICAL TEST: Different intervals should return different results
|
||||||
|
# Month interval: median of monthly totals (if all in same month) vs individual transactions
|
||||||
|
# Week interval: median of weekly totals [300, 1000, 800] = 800 vs individual transactions [100,200,400,600,800] = 400
|
||||||
|
refute_equal month_median, week_median, "Different intervals should return different statistical results when data spans multiple time periods"
|
||||||
|
|
||||||
|
# Both should still be numeric
|
||||||
|
assert month_median.is_a?(Numeric)
|
||||||
|
assert week_median.is_a?(Numeric)
|
||||||
|
assert month_median > 0
|
||||||
|
assert week_median > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
# NEW TESTS: Edge Cases
|
||||||
|
test "handles empty dataset gracefully" do
|
||||||
|
# Create a truly empty family
|
||||||
|
empty_family = Family.create!(name: "Empty Test Family", currency: "USD")
|
||||||
|
income_statement = IncomeStatement.new(empty_family)
|
||||||
|
|
||||||
|
# Should return 0 for statistical measures
|
||||||
|
assert_equal 0, income_statement.median_expense(interval: "month")
|
||||||
|
assert_equal 0, income_statement.median_income(interval: "month")
|
||||||
|
assert_equal 0, income_statement.avg_expense(interval: "month")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles category not found gracefully" do
|
||||||
|
nonexistent_category = Category.new(id: 99999, name: "Nonexistent")
|
||||||
|
|
||||||
|
income_statement = IncomeStatement.new(@family)
|
||||||
|
|
||||||
|
assert_equal 0, income_statement.median_expense(interval: "month", category: nonexistent_category)
|
||||||
|
assert_equal 0, income_statement.avg_expense(interval: "month", category: nonexistent_category)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles transactions without categories" do
|
||||||
|
# Create transaction without category
|
||||||
|
create_transaction(account: @checking_account, amount: 150, category: nil)
|
||||||
|
|
||||||
|
income_statement = IncomeStatement.new(@family)
|
||||||
|
totals = income_statement.totals
|
||||||
|
|
||||||
|
# Should still include uncategorized transaction in totals
|
||||||
|
assert_equal 5, totals.transactions_count
|
||||||
|
assert_equal Money.new(1050, @family.currency), totals.expense_money # 900 + 150
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
332
test/models/transaction/search_test.rb
Normal file
332
test/models/transaction/search_test.rb
Normal file
|
@ -0,0 +1,332 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Transaction::SearchTest < ActiveSupport::TestCase
|
||||||
|
include EntriesTestHelper
|
||||||
|
|
||||||
|
setup do
|
||||||
|
@family = families(:dylan_family)
|
||||||
|
@checking_account = accounts(:depository)
|
||||||
|
@credit_card_account = accounts(:credit_card)
|
||||||
|
@loan_account = accounts(:loan)
|
||||||
|
|
||||||
|
# Clean up existing entries/transactions from fixtures to ensure test isolation
|
||||||
|
@family.accounts.each { |account| account.entries.delete_all }
|
||||||
|
end
|
||||||
|
|
||||||
|
test "search filters by transaction types using kind enum" do
|
||||||
|
# Create different types of transactions using the helper method
|
||||||
|
standard_entry = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 100,
|
||||||
|
category: categories(:food_and_drink),
|
||||||
|
kind: "standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
transfer_entry = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 200,
|
||||||
|
kind: "funds_movement"
|
||||||
|
)
|
||||||
|
|
||||||
|
payment_entry = create_transaction(
|
||||||
|
account: @credit_card_account,
|
||||||
|
amount: -300,
|
||||||
|
kind: "cc_payment"
|
||||||
|
)
|
||||||
|
|
||||||
|
loan_payment_entry = create_transaction(
|
||||||
|
account: @loan_account,
|
||||||
|
amount: 400,
|
||||||
|
kind: "loan_payment"
|
||||||
|
)
|
||||||
|
|
||||||
|
one_time_entry = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 500,
|
||||||
|
kind: "one_time"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test transfer type filter (includes loan_payment)
|
||||||
|
transfer_results = Transaction::Search.new(@family, filters: { types: [ "transfer" ] }).transactions_scope
|
||||||
|
transfer_ids = transfer_results.pluck(:id)
|
||||||
|
|
||||||
|
assert_includes transfer_ids, transfer_entry.entryable.id
|
||||||
|
assert_includes transfer_ids, payment_entry.entryable.id
|
||||||
|
assert_includes transfer_ids, loan_payment_entry.entryable.id
|
||||||
|
assert_not_includes transfer_ids, one_time_entry.entryable.id
|
||||||
|
assert_not_includes transfer_ids, standard_entry.entryable.id
|
||||||
|
|
||||||
|
# Test expense type filter (excludes transfer kinds but includes one_time)
|
||||||
|
expense_results = Transaction::Search.new(@family, filters: { types: [ "expense" ] }).transactions_scope
|
||||||
|
expense_ids = expense_results.pluck(:id)
|
||||||
|
|
||||||
|
assert_includes expense_ids, standard_entry.entryable.id
|
||||||
|
assert_includes expense_ids, one_time_entry.entryable.id
|
||||||
|
assert_not_includes expense_ids, loan_payment_entry.entryable.id
|
||||||
|
assert_not_includes expense_ids, transfer_entry.entryable.id
|
||||||
|
assert_not_includes expense_ids, payment_entry.entryable.id
|
||||||
|
|
||||||
|
# Test income type filter
|
||||||
|
income_entry = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: -600,
|
||||||
|
kind: "standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
income_results = Transaction::Search.new(@family, filters: { types: [ "income" ] }).transactions_scope
|
||||||
|
income_ids = income_results.pluck(:id)
|
||||||
|
|
||||||
|
assert_includes income_ids, income_entry.entryable.id
|
||||||
|
assert_not_includes income_ids, standard_entry.entryable.id
|
||||||
|
assert_not_includes income_ids, loan_payment_entry.entryable.id
|
||||||
|
assert_not_includes income_ids, transfer_entry.entryable.id
|
||||||
|
|
||||||
|
# Test combined expense and income filter (excludes transfer kinds but includes one_time)
|
||||||
|
non_transfer_results = Transaction::Search.new(@family, filters: { types: [ "expense", "income" ] }).transactions_scope
|
||||||
|
non_transfer_ids = non_transfer_results.pluck(:id)
|
||||||
|
|
||||||
|
assert_includes non_transfer_ids, standard_entry.entryable.id
|
||||||
|
assert_includes non_transfer_ids, income_entry.entryable.id
|
||||||
|
assert_includes non_transfer_ids, one_time_entry.entryable.id
|
||||||
|
assert_not_includes non_transfer_ids, loan_payment_entry.entryable.id
|
||||||
|
assert_not_includes non_transfer_ids, transfer_entry.entryable.id
|
||||||
|
assert_not_includes non_transfer_ids, payment_entry.entryable.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "search category filter handles uncategorized transactions correctly with kind filtering" do
|
||||||
|
# Create uncategorized transactions of different kinds
|
||||||
|
uncategorized_standard = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 100,
|
||||||
|
kind: "standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
uncategorized_transfer = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 200,
|
||||||
|
kind: "funds_movement"
|
||||||
|
)
|
||||||
|
|
||||||
|
uncategorized_loan_payment = create_transaction(
|
||||||
|
account: @loan_account,
|
||||||
|
amount: 300,
|
||||||
|
kind: "loan_payment"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Search for uncategorized transactions
|
||||||
|
uncategorized_results = Transaction::Search.new(@family, filters: { categories: [ "Uncategorized" ] }).transactions_scope
|
||||||
|
uncategorized_ids = uncategorized_results.pluck(:id)
|
||||||
|
|
||||||
|
# Should include standard uncategorized transactions
|
||||||
|
assert_includes uncategorized_ids, uncategorized_standard.entryable.id
|
||||||
|
# Should include loan_payment since it's treated specially in category logic
|
||||||
|
assert_includes uncategorized_ids, uncategorized_loan_payment.entryable.id
|
||||||
|
|
||||||
|
# Should exclude transfer transactions even if uncategorized
|
||||||
|
assert_not_includes uncategorized_ids, uncategorized_transfer.entryable.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "new family-based API works correctly" do
|
||||||
|
# Create transactions for testing
|
||||||
|
transaction1 = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 100,
|
||||||
|
category: categories(:food_and_drink),
|
||||||
|
kind: "standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
transaction2 = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 200,
|
||||||
|
kind: "funds_movement"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test new family-based API
|
||||||
|
search = Transaction::Search.new(@family, filters: { types: [ "expense" ] })
|
||||||
|
results = search.transactions_scope
|
||||||
|
result_ids = results.pluck(:id)
|
||||||
|
|
||||||
|
# Should include expense transactions
|
||||||
|
assert_includes result_ids, transaction1.entryable.id
|
||||||
|
# Should exclude transfer transactions
|
||||||
|
assert_not_includes result_ids, transaction2.entryable.id
|
||||||
|
|
||||||
|
# Test that the relation builds from family.transactions correctly
|
||||||
|
assert_equal @family.transactions.joins(entry: :account).where(
|
||||||
|
"entries.amount >= 0 AND NOT (transactions.kind IN ('funds_movement', 'cc_payment', 'loan_payment'))"
|
||||||
|
).count, results.count
|
||||||
|
end
|
||||||
|
|
||||||
|
test "family-based API requires family parameter" do
|
||||||
|
assert_raises(NoMethodError) do
|
||||||
|
search = Transaction::Search.new({ types: [ "expense" ] })
|
||||||
|
search.transactions_scope # This will fail when trying to call .transactions on a Hash
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Totals method tests (lifted from Transaction::TotalsTest)
|
||||||
|
|
||||||
|
test "totals computes basic expense and income totals" do
|
||||||
|
# Create expense transaction
|
||||||
|
expense_entry = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 100,
|
||||||
|
category: categories(:food_and_drink),
|
||||||
|
kind: "standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create income transaction
|
||||||
|
income_entry = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: -200,
|
||||||
|
kind: "standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
search = Transaction::Search.new(@family)
|
||||||
|
totals = search.totals
|
||||||
|
|
||||||
|
assert_equal 2, totals.count
|
||||||
|
assert_equal Money.new(100, "USD"), totals.expense_money # $100
|
||||||
|
assert_equal Money.new(200, "USD"), totals.income_money # $200
|
||||||
|
end
|
||||||
|
|
||||||
|
test "totals handles multi-currency transactions with exchange rates" do
|
||||||
|
# Create EUR transaction
|
||||||
|
eur_entry = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 100,
|
||||||
|
currency: "EUR",
|
||||||
|
kind: "standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create exchange rate EUR -> USD
|
||||||
|
ExchangeRate.create!(
|
||||||
|
from_currency: "EUR",
|
||||||
|
to_currency: "USD",
|
||||||
|
rate: 1.1,
|
||||||
|
date: eur_entry.date
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create USD transaction
|
||||||
|
usd_entry = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 50,
|
||||||
|
currency: "USD",
|
||||||
|
kind: "standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
search = Transaction::Search.new(@family)
|
||||||
|
totals = search.totals
|
||||||
|
|
||||||
|
assert_equal 2, totals.count
|
||||||
|
# EUR 100 * 1.1 + USD 50 = 110 + 50 = 160
|
||||||
|
assert_equal Money.new(160, "USD"), totals.expense_money
|
||||||
|
assert_equal Money.new(0, "USD"), totals.income_money
|
||||||
|
end
|
||||||
|
|
||||||
|
test "totals handles missing exchange rates gracefully" do
|
||||||
|
# Create EUR transaction without exchange rate
|
||||||
|
eur_entry = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 100,
|
||||||
|
currency: "EUR",
|
||||||
|
kind: "standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
search = Transaction::Search.new(@family)
|
||||||
|
totals = search.totals
|
||||||
|
|
||||||
|
assert_equal 1, totals.count
|
||||||
|
# Should use rate of 1 when exchange rate is missing
|
||||||
|
assert_equal Money.new(100, "USD"), totals.expense_money # EUR 100 * 1
|
||||||
|
assert_equal Money.new(0, "USD"), totals.income_money
|
||||||
|
end
|
||||||
|
|
||||||
|
test "totals respects category filters" do
|
||||||
|
# Create transactions in different categories
|
||||||
|
food_entry = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 100,
|
||||||
|
category: categories(:food_and_drink),
|
||||||
|
kind: "standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
other_entry = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 50,
|
||||||
|
category: categories(:income),
|
||||||
|
kind: "standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by food category only
|
||||||
|
search = Transaction::Search.new(@family, filters: { categories: [ "Food & Drink" ] })
|
||||||
|
totals = search.totals
|
||||||
|
|
||||||
|
assert_equal 1, totals.count
|
||||||
|
assert_equal Money.new(100, "USD"), totals.expense_money # Only food transaction
|
||||||
|
assert_equal Money.new(0, "USD"), totals.income_money
|
||||||
|
end
|
||||||
|
|
||||||
|
test "totals respects type filters" do
|
||||||
|
# Create expense and income transactions
|
||||||
|
expense_entry = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 100,
|
||||||
|
kind: "standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
income_entry = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: -200,
|
||||||
|
kind: "standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by expense type only
|
||||||
|
search = Transaction::Search.new(@family, filters: { types: [ "expense" ] })
|
||||||
|
totals = search.totals
|
||||||
|
|
||||||
|
assert_equal 1, totals.count
|
||||||
|
assert_equal Money.new(100, "USD"), totals.expense_money
|
||||||
|
assert_equal Money.new(0, "USD"), totals.income_money
|
||||||
|
end
|
||||||
|
|
||||||
|
test "totals handles empty results" do
|
||||||
|
search = Transaction::Search.new(@family)
|
||||||
|
totals = search.totals
|
||||||
|
|
||||||
|
assert_equal 0, totals.count
|
||||||
|
assert_equal Money.new(0, "USD"), totals.expense_money
|
||||||
|
assert_equal Money.new(0, "USD"), totals.income_money
|
||||||
|
end
|
||||||
|
|
||||||
|
test "totals respects excluded transactions filter from search" do
|
||||||
|
# Create an excluded transaction (should be excluded by default)
|
||||||
|
excluded_entry = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 100,
|
||||||
|
kind: "standard"
|
||||||
|
)
|
||||||
|
excluded_entry.update!(excluded: true) # Marks it as excluded
|
||||||
|
|
||||||
|
# Create a normal transaction
|
||||||
|
normal_entry = create_transaction(
|
||||||
|
account: @checking_account,
|
||||||
|
amount: 50,
|
||||||
|
kind: "standard"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default behavior should exclude excluded transactions
|
||||||
|
search = Transaction::Search.new(@family)
|
||||||
|
totals = search.totals
|
||||||
|
|
||||||
|
assert_equal 1, totals.count
|
||||||
|
assert_equal Money.new(50, "USD"), totals.expense_money # Only non-excluded transaction
|
||||||
|
|
||||||
|
# Explicitly include excluded transactions
|
||||||
|
search_with_excluded = Transaction::Search.new(@family, filters: { excluded_transactions: true })
|
||||||
|
totals_with_excluded = search_with_excluded.totals
|
||||||
|
|
||||||
|
assert_equal 2, totals_with_excluded.count
|
||||||
|
assert_equal Money.new(150, "USD"), totals_with_excluded.expense_money # Both transactions
|
||||||
|
end
|
||||||
|
end
|
166
test/models/transfer/creator_test.rb
Normal file
166
test/models/transfer/creator_test.rb
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Transfer::CreatorTest < ActiveSupport::TestCase
|
||||||
|
setup do
|
||||||
|
@family = families(:dylan_family)
|
||||||
|
@source_account = accounts(:depository)
|
||||||
|
@destination_account = accounts(:investment)
|
||||||
|
@date = Date.current
|
||||||
|
@amount = 100
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creates basic transfer" do
|
||||||
|
creator = Transfer::Creator.new(
|
||||||
|
family: @family,
|
||||||
|
source_account_id: @source_account.id,
|
||||||
|
destination_account_id: @destination_account.id,
|
||||||
|
date: @date,
|
||||||
|
amount: @amount
|
||||||
|
)
|
||||||
|
|
||||||
|
transfer = creator.create
|
||||||
|
|
||||||
|
assert transfer.persisted?
|
||||||
|
assert_equal "confirmed", transfer.status
|
||||||
|
assert transfer.regular_transfer?
|
||||||
|
assert_equal "transfer", transfer.transfer_type
|
||||||
|
|
||||||
|
# Verify outflow transaction (from source account)
|
||||||
|
outflow = transfer.outflow_transaction
|
||||||
|
assert_equal "funds_movement", outflow.kind
|
||||||
|
assert_equal @amount, outflow.entry.amount
|
||||||
|
assert_equal @source_account.currency, outflow.entry.currency
|
||||||
|
assert_equal "Transfer to #{@destination_account.name}", outflow.entry.name
|
||||||
|
|
||||||
|
# Verify inflow transaction (to destination account)
|
||||||
|
inflow = transfer.inflow_transaction
|
||||||
|
assert_equal "funds_movement", inflow.kind
|
||||||
|
assert_equal(@amount * -1, inflow.entry.amount)
|
||||||
|
assert_equal @destination_account.currency, inflow.entry.currency
|
||||||
|
assert_equal "Transfer from #{@source_account.name}", inflow.entry.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creates multi-currency transfer" do
|
||||||
|
# Use crypto account which has USD currency but different from source
|
||||||
|
crypto_account = accounts(:crypto)
|
||||||
|
|
||||||
|
creator = Transfer::Creator.new(
|
||||||
|
family: @family,
|
||||||
|
source_account_id: @source_account.id,
|
||||||
|
destination_account_id: crypto_account.id,
|
||||||
|
date: @date,
|
||||||
|
amount: @amount
|
||||||
|
)
|
||||||
|
|
||||||
|
transfer = creator.create
|
||||||
|
|
||||||
|
assert transfer.persisted?
|
||||||
|
assert transfer.regular_transfer?
|
||||||
|
assert_equal "transfer", transfer.transfer_type
|
||||||
|
|
||||||
|
# Verify outflow transaction
|
||||||
|
outflow = transfer.outflow_transaction
|
||||||
|
assert_equal "funds_movement", outflow.kind
|
||||||
|
assert_equal "Transfer to #{crypto_account.name}", outflow.entry.name
|
||||||
|
|
||||||
|
# Verify inflow transaction with currency handling
|
||||||
|
inflow = transfer.inflow_transaction
|
||||||
|
assert_equal "funds_movement", inflow.kind
|
||||||
|
assert_equal "Transfer from #{@source_account.name}", inflow.entry.name
|
||||||
|
assert_equal crypto_account.currency, inflow.entry.currency
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creates loan payment" do
|
||||||
|
loan_account = accounts(:loan)
|
||||||
|
|
||||||
|
creator = Transfer::Creator.new(
|
||||||
|
family: @family,
|
||||||
|
source_account_id: @source_account.id,
|
||||||
|
destination_account_id: loan_account.id,
|
||||||
|
date: @date,
|
||||||
|
amount: @amount
|
||||||
|
)
|
||||||
|
|
||||||
|
transfer = creator.create
|
||||||
|
|
||||||
|
assert transfer.persisted?
|
||||||
|
assert transfer.loan_payment?
|
||||||
|
assert_equal "loan_payment", transfer.transfer_type
|
||||||
|
|
||||||
|
# Verify outflow transaction is marked as loan payment
|
||||||
|
outflow = transfer.outflow_transaction
|
||||||
|
assert_equal "loan_payment", outflow.kind
|
||||||
|
assert_equal "Payment to #{loan_account.name}", outflow.entry.name
|
||||||
|
|
||||||
|
# Verify inflow transaction
|
||||||
|
inflow = transfer.inflow_transaction
|
||||||
|
assert_equal "funds_movement", inflow.kind
|
||||||
|
assert_equal "Payment from #{@source_account.name}", inflow.entry.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creates credit card payment" do
|
||||||
|
credit_card_account = accounts(:credit_card)
|
||||||
|
|
||||||
|
creator = Transfer::Creator.new(
|
||||||
|
family: @family,
|
||||||
|
source_account_id: @source_account.id,
|
||||||
|
destination_account_id: credit_card_account.id,
|
||||||
|
date: @date,
|
||||||
|
amount: @amount
|
||||||
|
)
|
||||||
|
|
||||||
|
transfer = creator.create
|
||||||
|
|
||||||
|
assert transfer.persisted?
|
||||||
|
assert transfer.liability_payment?
|
||||||
|
assert_equal "liability_payment", transfer.transfer_type
|
||||||
|
|
||||||
|
# Verify outflow transaction is marked as payment for liability
|
||||||
|
outflow = transfer.outflow_transaction
|
||||||
|
assert_equal "cc_payment", outflow.kind
|
||||||
|
assert_equal "Payment to #{credit_card_account.name}", outflow.entry.name
|
||||||
|
|
||||||
|
# Verify inflow transaction
|
||||||
|
inflow = transfer.inflow_transaction
|
||||||
|
assert_equal "funds_movement", inflow.kind
|
||||||
|
assert_equal "Payment from #{@source_account.name}", inflow.entry.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "raises error when source account ID is invalid" do
|
||||||
|
assert_raises(ActiveRecord::RecordNotFound) do
|
||||||
|
Transfer::Creator.new(
|
||||||
|
family: @family,
|
||||||
|
source_account_id: 99999,
|
||||||
|
destination_account_id: @destination_account.id,
|
||||||
|
date: @date,
|
||||||
|
amount: @amount
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "raises error when destination account ID is invalid" do
|
||||||
|
assert_raises(ActiveRecord::RecordNotFound) do
|
||||||
|
Transfer::Creator.new(
|
||||||
|
family: @family,
|
||||||
|
source_account_id: @source_account.id,
|
||||||
|
destination_account_id: 99999,
|
||||||
|
date: @date,
|
||||||
|
amount: @amount
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "raises error when source account belongs to different family" do
|
||||||
|
other_family = families(:empty)
|
||||||
|
|
||||||
|
assert_raises(ActiveRecord::RecordNotFound) do
|
||||||
|
Transfer::Creator.new(
|
||||||
|
family: other_family,
|
||||||
|
source_account_id: @source_account.id,
|
||||||
|
destination_account_id: @destination_account.id,
|
||||||
|
date: @date,
|
||||||
|
amount: @amount
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -93,36 +93,6 @@ class TransferTest < ActiveSupport::TestCase
|
||||||
assert_equal "Must be from same family", transfer.errors.full_messages.first
|
assert_equal "Must be from same family", transfer.errors.full_messages.first
|
||||||
end
|
end
|
||||||
|
|
||||||
test "from_accounts converts amounts to the to_account's currency" do
|
|
||||||
accounts(:depository).update!(currency: "EUR")
|
|
||||||
|
|
||||||
eur_account = accounts(:depository).reload
|
|
||||||
usd_account = accounts(:credit_card)
|
|
||||||
|
|
||||||
ExchangeRate.create!(
|
|
||||||
from_currency: "EUR",
|
|
||||||
to_currency: "USD",
|
|
||||||
rate: 1.1,
|
|
||||||
date: Date.current,
|
|
||||||
)
|
|
||||||
|
|
||||||
transfer = Transfer.from_accounts(
|
|
||||||
from_account: eur_account,
|
|
||||||
to_account: usd_account,
|
|
||||||
date: Date.current,
|
|
||||||
amount: 500,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_equal 500, transfer.outflow_transaction.entry.amount
|
|
||||||
assert_equal "EUR", transfer.outflow_transaction.entry.currency
|
|
||||||
assert_equal -550, transfer.inflow_transaction.entry.amount
|
|
||||||
assert_equal "USD", transfer.inflow_transaction.entry.currency
|
|
||||||
|
|
||||||
assert_difference -> { Transfer.count } => 1 do
|
|
||||||
transfer.save!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "transaction can only belong to one transfer" do
|
test "transaction can only belong to one transfer" do
|
||||||
outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500)
|
outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500)
|
||||||
inflow_entry1 = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500)
|
inflow_entry1 = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
module EntriesTestHelper
|
module EntriesTestHelper
|
||||||
def create_transaction(attributes = {})
|
def create_transaction(attributes = {})
|
||||||
entry_attributes = attributes.except(:category, :tags, :merchant)
|
entry_attributes = attributes.except(:category, :tags, :merchant, :kind)
|
||||||
transaction_attributes = attributes.slice(:category, :tags, :merchant)
|
transaction_attributes = attributes.slice(:category, :tags, :merchant, :kind)
|
||||||
|
|
||||||
entry_defaults = {
|
entry_defaults = {
|
||||||
account: accounts(:depository),
|
account: accounts(:depository),
|
||||||
|
|
|
@ -24,7 +24,7 @@ class TradesTest < ApplicationSystemTestCase
|
||||||
fill_in "Ticker symbol", with: "AAPL"
|
fill_in "Ticker symbol", with: "AAPL"
|
||||||
fill_in "Date", with: Date.current
|
fill_in "Date", with: Date.current
|
||||||
fill_in "Quantity", with: shares_qty
|
fill_in "Quantity", with: shares_qty
|
||||||
fill_in "entry[price]", with: 214.23
|
fill_in "model[price]", with: 214.23
|
||||||
|
|
||||||
click_button "Add transaction"
|
click_button "Add transaction"
|
||||||
|
|
||||||
|
@ -45,7 +45,7 @@ class TradesTest < ApplicationSystemTestCase
|
||||||
fill_in "Ticker symbol", with: "AAPL"
|
fill_in "Ticker symbol", with: "AAPL"
|
||||||
fill_in "Date", with: Date.current
|
fill_in "Date", with: Date.current
|
||||||
fill_in "Quantity", with: qty
|
fill_in "Quantity", with: qty
|
||||||
fill_in "entry[price]", with: 215.33
|
fill_in "model[price]", with: 215.33
|
||||||
|
|
||||||
click_button "Add transaction"
|
click_button "Add transaction"
|
||||||
|
|
||||||
|
|
|
@ -189,7 +189,7 @@ class TransactionsTest < ApplicationSystemTestCase
|
||||||
end
|
end
|
||||||
select "Deposit", from: "Type"
|
select "Deposit", from: "Type"
|
||||||
fill_in "Date", with: transfer_date
|
fill_in "Date", with: transfer_date
|
||||||
fill_in "entry[amount]", with: 175.25
|
fill_in "model[amount]", with: 175.25
|
||||||
click_button "Add transaction"
|
click_button "Add transaction"
|
||||||
within "#entry-group-" + transfer_date.to_s do
|
within "#entry-group-" + transfer_date.to_s do
|
||||||
assert_text "175.25"
|
assert_text "175.25"
|
||||||
|
@ -203,6 +203,7 @@ class TransactionsTest < ApplicationSystemTestCase
|
||||||
inflow_entry = create_transaction("inflow", 1.day.ago.to_date, -500, account: investment_account)
|
inflow_entry = create_transaction("inflow", 1.day.ago.to_date, -500, account: investment_account)
|
||||||
@user.family.auto_match_transfers!
|
@user.family.auto_match_transfers!
|
||||||
visit transactions_url
|
visit transactions_url
|
||||||
|
|
||||||
within "#entry-group-" + Date.current.to_s + "-totals" do
|
within "#entry-group-" + Date.current.to_s + "-totals" do
|
||||||
assert_text "-$100.00" # transaction eleven from setup
|
assert_text "-$100.00" # transaction eleven from setup
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue