Compare commits
97 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52d3528361 | ||
|
|
d3d9af8bce | ||
|
|
0149ca4ea1 | ||
|
|
30f7c120e1 | ||
|
|
949d3d80fa | ||
|
|
c28dd8f940 | ||
|
|
277e4476d9 | ||
|
|
b9341ac302 | ||
|
|
86741401c3 | ||
|
|
edf44bec03 | ||
|
|
5178928b68 | ||
|
|
ac0ff35360 | ||
|
|
04037b8943 | ||
|
|
cb13fd2245 | ||
|
|
eebc07d75e | ||
|
|
c30c1b9698 | ||
|
|
d3971f9cee | ||
|
|
9e4b931612 | ||
|
|
b44da70836 | ||
|
|
0db75a019b | ||
|
|
ee572d8d1f | ||
|
|
33d007a07b | ||
|
|
3673ab8f03 | ||
|
|
fb42c2ad43 | ||
|
|
9172eb931b | ||
|
|
0c8cf7e217 | ||
|
|
0bbf7f82b7 | ||
|
|
c05ee9b572 | ||
|
|
38c2b4670c | ||
|
|
f82ce59dad | ||
|
|
166ed4b1ea | ||
|
|
0c0db44b7f | ||
|
|
cd254fd19b | ||
|
|
0d20be4905 | ||
|
|
cf861ccff9 | ||
|
|
525439e44d | ||
|
|
e1efe97e6f | ||
|
|
52c729dc33 | ||
|
|
de9723d63a | ||
|
|
eef4c2643b | ||
|
|
359bceb58e | ||
|
|
e856691c86 | ||
|
|
4433488562 | ||
|
|
37ae51f68a | ||
|
|
793a6027a3 | ||
|
|
4d20b5f2d4 | ||
|
|
7966c44d7f | ||
|
|
30b2ff7aa6 | ||
|
|
f85fdba366 | ||
|
|
0cb4e968a0 | ||
|
|
8ebf18e04d | ||
|
|
0c1ff00c1e | ||
|
|
e6528bafec | ||
|
|
1b6ce6af45 | ||
|
|
4527482aa2 | ||
|
|
707c5ca0ca | ||
|
|
c70a08aca2 | ||
|
|
9dda2606d5 | ||
|
|
acf3564a86 | ||
|
|
1f6f55c4a8 | ||
|
|
0691041d37 | ||
|
|
b437bb20c4 | ||
|
|
3c64f3ff3b | ||
|
|
82d3b8bcaf | ||
|
|
14c4b9e93c | ||
|
|
150fce41a8 | ||
|
|
67f65d399e | ||
|
|
72fe6d87f0 | ||
|
|
94be117a02 | ||
|
|
f3c44464be | ||
|
|
c0908f454a | ||
|
|
e05f03b314 | ||
|
|
6bca35fa22 | ||
|
|
6fa40e0fa2 | ||
|
|
f315370512 | ||
|
|
6e74414cb2 | ||
|
|
9ad04a82cb | ||
|
|
7c878697f4 | ||
|
|
cdb134077d | ||
|
|
65aeab4681 | ||
|
|
e0d2b951d6 | ||
|
|
4eeca00121 | ||
|
|
07a7a6b1aa | ||
|
|
edda5cb35b | ||
|
|
ea8309eedd | ||
|
|
453a54e5e6 | ||
|
|
c70c8b6d86 | ||
|
|
f2a2d2f7e4 | ||
|
|
0a21c92643 | ||
|
|
2c5f647f53 | ||
|
|
11f58537db | ||
|
|
6231814e1e | ||
|
|
7645a9ec56 | ||
|
|
08b59ad5fe | ||
|
|
02adba5280 | ||
|
|
1f5721a8b1 | ||
|
|
7ba9830db5 |
64
.ai/cursorrules.md
Normal file
64
.ai/cursorrules.md
Normal file
@@ -0,0 +1,64 @@
|
||||
<!-- Copy this file to .cursorrules in the root of the project on your local machine if you'd like to use these rules with Cursor. -->
|
||||
|
||||
You are an expert in Ruby, Ruby on Rails, Postgres, Tailwind, Stimulus, Hotwire and Turbo and always use the latest stable versions of those technologies.
|
||||
|
||||
**Code Style and Structure**
|
||||
- Write concise, technical Ruby code with accurate examples.
|
||||
- Prefer iteration and modularization over code duplication.
|
||||
- Use descriptive variable names with auxiliary verbs (e.g., is_loading, has_error).
|
||||
- Structure files: models, controllers, views, helpers, services, jobs, mailers.
|
||||
|
||||
**Naming Conventions**
|
||||
- Use snake_case for file names and directories (e.g., app/models/user_profile.rb).
|
||||
- Use CamelCase for classes and modules (e.g., UserProfile).
|
||||
|
||||
**Ruby on Rails Usage**
|
||||
- Use Rails conventions for MVC structure.
|
||||
- Favor scopes over class methods for queries.
|
||||
- Use strong parameters for mass assignment protection.
|
||||
- Use partials to DRY up views.
|
||||
|
||||
**Syntax and Formatting**
|
||||
- Use two spaces for indentation.
|
||||
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
|
||||
- Use descriptive method names and keep methods short.
|
||||
|
||||
**Commenting Code**
|
||||
- Write clear, concise comments to explain the purpose of individual functions and methods.
|
||||
- Use comments to describe the intent and functionality of complex logic.
|
||||
- Avoid redundant comments that state the obvious.
|
||||
|
||||
**UI and Styling**
|
||||
- Use Tailwind CSS for styling.
|
||||
- Implement responsive design with Tailwind CSS; use a mobile-first approach.
|
||||
- Use Stimulus for JavaScript behavior.
|
||||
- Use Turbo for asynchronous actions and updates.
|
||||
|
||||
**Performance Optimization**
|
||||
- Use eager loading to avoid N+1 queries.
|
||||
- Cache expensive queries and partials where appropriate.
|
||||
- Use background jobs for long-running tasks.
|
||||
- Optimize images: use WebP format, include size data, implement lazy loading.
|
||||
|
||||
**Database Querying & Data Model Creation**
|
||||
- Use ActiveRecord for data querying and model creation.
|
||||
- Favor database constraints and indexes for data integrity and performance.
|
||||
- Use migrations to manage schema changes.
|
||||
|
||||
**Key Conventions**
|
||||
- Follow Rails best practices for RESTful routing.
|
||||
- Optimize for performance and security.
|
||||
- Use environment variables for configuration.
|
||||
- Write tests for models, controllers, and features.
|
||||
|
||||
**AI Guidelines**
|
||||
- Follow the user’s requirements carefully & to the letter.
|
||||
- Confirm, then write code!
|
||||
- Suggest solutions that I didn't think about—anticipate my needs
|
||||
- Focus on readability over being performant.
|
||||
- Fully implement all requested functionality.
|
||||
- Leave NO todo’s, placeholders or missing pieces.
|
||||
- Don't say things like "additional logic can be added here" — instead, add the logic.
|
||||
- Be concise. Minimize any other prose.
|
||||
- Consider new technologies and contrarian ideas, not just the conventional wisdom
|
||||
- If I ask for adjustments to code, do not repeat all of my code unnecessarily. Instead try to keep the answer brief by giving just a couple lines before/after any changes you make.
|
||||
@@ -1,4 +1,4 @@
|
||||
ARG RUBY_VERSION=3.3.1
|
||||
ARG RUBY_VERSION=3.3.4
|
||||
FROM ruby:${RUBY_VERSION}-slim-bullseye
|
||||
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
|
||||
7
.editorconfig
Normal file
7
.editorconfig
Normal file
@@ -0,0 +1,7 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
17
.env.test.example
Normal file
17
.env.test.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# ================
|
||||
# Data Providers
|
||||
# ---------------------------------------------------------------------------------
|
||||
# Uncomment and fill in live keys when you need to generate a VCR cassette fixture
|
||||
# ================
|
||||
|
||||
# SYNTH_API_KEY=<add live key here>
|
||||
|
||||
# ================
|
||||
# Miscellaneous
|
||||
# ================
|
||||
|
||||
# Set to true if you want SimpleCov reports generated
|
||||
COVERAGE=false
|
||||
|
||||
# Set to true to run test suite serially
|
||||
DISABLE_PARALLELIZATION=false
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,7 @@
|
||||
/.env*
|
||||
!/.env*.erb
|
||||
!.env.example
|
||||
!.env.test.example
|
||||
|
||||
# Ignore all logfiles and tempfiles.
|
||||
/log/*
|
||||
@@ -58,3 +59,4 @@ compose-dev.yaml
|
||||
gcp-storage-keyfile.json
|
||||
|
||||
coverage
|
||||
.cursorrules
|
||||
21
.rubocop.yml
21
.rubocop.yml
@@ -1,8 +1,15 @@
|
||||
# Omakase Ruby styling for Rails
|
||||
inherit_gem: { rubocop-rails-omakase: rubocop.yml }
|
||||
inherit_gem:
|
||||
rubocop-rails-omakase: rubocop.yml
|
||||
|
||||
Layout/IndentationWidth:
|
||||
Enabled: true
|
||||
|
||||
# Overwrite or add rules to create your own house style
|
||||
#
|
||||
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
|
||||
# Layout/SpaceInsideArrayLiteralBrackets:
|
||||
# Enabled: false
|
||||
Layout/IndentationStyle:
|
||||
EnforcedStyle: spaces
|
||||
IndentationWidth: 2
|
||||
|
||||
Layout/IndentationConsistency:
|
||||
Enabled: true
|
||||
|
||||
Layout/SpaceInsidePercentLiteralDelimiters:
|
||||
Enabled: true
|
||||
@@ -1 +1 @@
|
||||
3.3.1
|
||||
3.3.4
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
|
||||
ARG RUBY_VERSION=3.3.1
|
||||
ARG RUBY_VERSION=3.3.4
|
||||
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
|
||||
|
||||
# Rails app lives here
|
||||
|
||||
8
Gemfile
8
Gemfile
@@ -3,7 +3,7 @@ source "https://rubygems.org"
|
||||
ruby file: ".ruby-version"
|
||||
|
||||
# Rails
|
||||
gem "rails", github: "rails/rails", branch: "7-2-stable"
|
||||
gem "rails", "~> 7.2.1"
|
||||
|
||||
# Drivers
|
||||
gem "pg", "~> 1.5"
|
||||
@@ -42,20 +42,20 @@ gem "inline_svg"
|
||||
gem "octokit"
|
||||
gem "pagy"
|
||||
gem "rails-settings-cached"
|
||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||
gem "tzinfo-data", platforms: %i[windows jruby]
|
||||
gem "csv"
|
||||
gem "redcarpet"
|
||||
|
||||
group :development, :test do
|
||||
gem "debug", platforms: %i[ mri windows ]
|
||||
gem "debug", platforms: %i[mri windows]
|
||||
gem "brakeman", require: false
|
||||
gem "rubocop-rails-omakase", require: false
|
||||
gem "i18n-tasks"
|
||||
gem "erb_lint"
|
||||
gem "dotenv-rails"
|
||||
end
|
||||
|
||||
group :development do
|
||||
gem "dotenv-rails"
|
||||
gem "hotwire-livereload"
|
||||
gem "letter_opener"
|
||||
gem "ruby-lsp-rails"
|
||||
|
||||
269
Gemfile.lock
269
Gemfile.lock
@@ -5,71 +5,69 @@ GIT
|
||||
lucide-rails (0.2.0)
|
||||
railties (>= 4.1.0)
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: 1b8903346000e1848e62e09429d325499af03b3f
|
||||
branch: 7-2-stable
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
actioncable (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
activejob (= 7.2.0.beta3)
|
||||
activerecord (= 7.2.0.beta3)
|
||||
activestorage (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
actionmailbox (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
activejob (= 7.2.1)
|
||||
activerecord (= 7.2.1)
|
||||
activestorage (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
actionview (= 7.2.0.beta3)
|
||||
activejob (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
actionmailer (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
actionview (= 7.2.1)
|
||||
activejob (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.2.0.beta3)
|
||||
actionview (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
actionpack (7.2.1)
|
||||
actionview (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4)
|
||||
rack (>= 2.2.4, < 3.2)
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
activerecord (= 7.2.0.beta3)
|
||||
activestorage (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
actiontext (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
activerecord (= 7.2.1)
|
||||
activestorage (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
actionview (7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
activejob (7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
activerecord (7.2.0.beta3)
|
||||
activemodel (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
activemodel (7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
activerecord (7.2.1)
|
||||
activemodel (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
activejob (= 7.2.0.beta3)
|
||||
activerecord (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
activestorage (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
activejob (= 7.2.1)
|
||||
activerecord (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.2.0.beta3)
|
||||
activesupport (7.2.1)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
@@ -78,51 +76,26 @@ GIT
|
||||
i18n (>= 1.6, < 2)
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
rails (7.2.0.beta3)
|
||||
actioncable (= 7.2.0.beta3)
|
||||
actionmailbox (= 7.2.0.beta3)
|
||||
actionmailer (= 7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
actiontext (= 7.2.0.beta3)
|
||||
actionview (= 7.2.0.beta3)
|
||||
activejob (= 7.2.0.beta3)
|
||||
activemodel (= 7.2.0.beta3)
|
||||
activerecord (= 7.2.0.beta3)
|
||||
activestorage (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.0.beta3)
|
||||
railties (7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
zeitwerk (~> 2.6)
|
||||
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
addressable (2.8.6)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.952.0)
|
||||
aws-sdk-core (3.201.1)
|
||||
aws-partitions (1.971.0)
|
||||
aws-sdk-core (3.203.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (~> 1.9)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.88.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sdk-kms (1.89.0)
|
||||
aws-sdk-core (~> 3, >= 3.203.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.156.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sdk-s3 (1.160.0)
|
||||
aws-sdk-core (~> 3, >= 3.203.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.8.0)
|
||||
aws-sigv4 (1.9.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
@@ -135,9 +108,9 @@ GEM
|
||||
smart_properties
|
||||
bigdecimal (3.1.8)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.3)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (6.1.2)
|
||||
brakeman (6.2.1)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
@@ -151,7 +124,7 @@ GEM
|
||||
xpath (~> 3.2)
|
||||
childprocess (5.0.0)
|
||||
climate_control (1.2.0)
|
||||
concurrent-ruby (1.3.3)
|
||||
concurrent-ruby (1.3.4)
|
||||
connection_pool (2.4.1)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
@@ -168,32 +141,37 @@ GEM
|
||||
dotenv (= 3.1.2)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.1)
|
||||
erb_lint (0.5.0)
|
||||
erb_lint (0.6.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
parser (>= 2.7.1.4)
|
||||
rainbow
|
||||
rubocop
|
||||
rubocop (>= 1)
|
||||
smart_properties
|
||||
erubi (1.13.0)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
faker (3.4.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.10.0)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
faraday (2.11.0)
|
||||
faraday-net_http (>= 2.0, < 3.4)
|
||||
logger
|
||||
faraday-net_http (3.1.0)
|
||||
faraday-net_http (3.3.0)
|
||||
net-http
|
||||
faraday-retry (2.2.1)
|
||||
faraday (~> 2.0)
|
||||
ffi (1.16.3)
|
||||
fugit (1.11.0)
|
||||
ffi (1.17.0-aarch64-linux-gnu)
|
||||
ffi (1.17.0-arm-linux-gnu)
|
||||
ffi (1.17.0-arm64-darwin)
|
||||
ffi (1.17.0-x86-linux-gnu)
|
||||
ffi (1.17.0-x86_64-darwin)
|
||||
ffi (1.17.0-x86_64-linux-gnu)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.1.0)
|
||||
good_job (4.2.1)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
@@ -202,7 +180,7 @@ GEM
|
||||
thor (>= 1.0.0)
|
||||
hashdiff (1.1.0)
|
||||
highline (3.0.1)
|
||||
hotwire-livereload (1.4.0)
|
||||
hotwire-livereload (1.4.1)
|
||||
actioncable (>= 6.0.0)
|
||||
listen (>= 3.0.0)
|
||||
railties (>= 6.0.0)
|
||||
@@ -218,14 +196,14 @@ GEM
|
||||
rails-i18n
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
terminal-table (>= 1.5.1)
|
||||
image_processing (1.12.2)
|
||||
image_processing (1.13.0)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
importmap-rails (2.0.1)
|
||||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
inline_svg (1.9.0)
|
||||
inline_svg (1.10.0)
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.7.2)
|
||||
@@ -243,7 +221,7 @@ GEM
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.6.0)
|
||||
logger (1.6.1)
|
||||
loofah (2.22.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
@@ -254,10 +232,10 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
mini_magick (4.12.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.24.1)
|
||||
mocha (2.4.2)
|
||||
minitest (5.25.1)
|
||||
mocha (2.4.5)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
net-http (0.4.1)
|
||||
@@ -272,29 +250,29 @@ GEM
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.6-aarch64-linux)
|
||||
nokogiri (1.16.7-aarch64-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-arm-linux)
|
||||
nokogiri (1.16.7-arm-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-arm64-darwin)
|
||||
nokogiri (1.16.7-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-x86-linux)
|
||||
nokogiri (1.16.7-x86-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-x86_64-darwin)
|
||||
nokogiri (1.16.7-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-x86_64-linux)
|
||||
nokogiri (1.16.7-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
octokit (9.1.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (9.0.2)
|
||||
parallel (1.24.0)
|
||||
parser (3.3.1.0)
|
||||
pagy (9.0.8)
|
||||
parallel (1.25.1)
|
||||
parser (3.3.4.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.6)
|
||||
pg (1.5.8)
|
||||
prism (0.30.0)
|
||||
propshaft (0.9.0)
|
||||
propshaft (0.9.1)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
@@ -305,7 +283,7 @@ GEM
|
||||
puma (6.4.2)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.7)
|
||||
rack-session (2.0.0)
|
||||
rack (>= 3.0.0)
|
||||
@@ -314,6 +292,20 @@ GEM
|
||||
rackup (2.1.0)
|
||||
rack (>= 3)
|
||||
webrick (~> 1.8)
|
||||
rails (7.2.1)
|
||||
actioncable (= 7.2.1)
|
||||
actionmailbox (= 7.2.1)
|
||||
actionmailer (= 7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
actiontext (= 7.2.1)
|
||||
actionview (= 7.2.1)
|
||||
activejob (= 7.2.1)
|
||||
activemodel (= 7.2.1)
|
||||
activerecord (= 7.2.1)
|
||||
activestorage (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.1)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -327,10 +319,18 @@ GEM
|
||||
rails-settings-cached (2.9.4)
|
||||
activerecord (>= 5.0.0)
|
||||
railties (>= 5.0.0)
|
||||
railties (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.10.1)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (3.5.2)
|
||||
logger
|
||||
@@ -338,17 +338,17 @@ GEM
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.0)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.9)
|
||||
reline (0.5.10)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.3.2)
|
||||
rexml (3.3.6)
|
||||
strscan
|
||||
rubocop (1.63.5)
|
||||
rubocop (1.65.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
regexp_parser (>= 2.4, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
@@ -371,31 +371,33 @@ GEM
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.17.8)
|
||||
ruby-lsp (0.17.13)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 0.29.0, < 0.31)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.3.11)
|
||||
ruby-lsp (>= 0.17.2, < 0.18.0)
|
||||
ruby-lsp-rails (0.3.13)
|
||||
ruby-lsp (>= 0.17.12, < 0.18.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.1)
|
||||
ruby-vips (2.2.2)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
selenium-webdriver (4.23.0)
|
||||
securerandom (0.3.1)
|
||||
selenium-webdriver (4.24.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.18.1)
|
||||
sentry-rails (5.19.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.18.1)
|
||||
sentry-ruby (5.18.1)
|
||||
sentry-ruby (~> 5.19.0)
|
||||
sentry-ruby (5.19.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
simplecov (0.22.0)
|
||||
@@ -405,27 +407,27 @@ GEM
|
||||
simplecov-html (0.12.3)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11491)
|
||||
sorbet-runtime (0.5.11528)
|
||||
stackprof (0.2.26)
|
||||
stimulus-rails (1.3.3)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.1)
|
||||
strscan (3.1.0)
|
||||
tailwindcss-rails (2.6.3)
|
||||
tailwindcss-rails (2.7.3)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.3-aarch64-linux)
|
||||
tailwindcss-rails (2.7.3-aarch64-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.3-arm-linux)
|
||||
tailwindcss-rails (2.7.3-arm-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.3-arm64-darwin)
|
||||
tailwindcss-rails (2.7.3-arm64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.3-x86_64-darwin)
|
||||
tailwindcss-rails (2.7.3-x86_64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.3-x86_64-linux)
|
||||
tailwindcss-rails (2.7.3-x86_64-linux)
|
||||
railties (>= 7.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
thor (1.3.1)
|
||||
thor (1.3.2)
|
||||
timeout (0.4.1)
|
||||
turbo-rails (2.0.6)
|
||||
actionpack (>= 6.0.0)
|
||||
@@ -434,9 +436,10 @@ GEM
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (2.5.0)
|
||||
uri (0.13.0)
|
||||
uri (0.13.1)
|
||||
useragent (0.16.10)
|
||||
vcr (6.2.0)
|
||||
vcr (6.3.1)
|
||||
base64
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
@@ -453,7 +456,7 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.6.16)
|
||||
zeitwerk (2.6.18)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -491,7 +494,7 @@ DEPENDENCIES
|
||||
pg (~> 1.5)
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
rails!
|
||||
rails (~> 7.2.1)
|
||||
rails-settings-cached
|
||||
redcarpet
|
||||
rubocop-rails-omakase
|
||||
@@ -510,7 +513,7 @@ DEPENDENCIES
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.1p55
|
||||
ruby 3.3.4p94
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.9
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
web: bin/rails server -b 0.0.0.0
|
||||
web: ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
|
||||
css: bin/rails tailwindcss:watch
|
||||
worker: bundle exec good_job start
|
||||
|
||||
@@ -42,7 +42,7 @@ The instructions below are for developers to get started with contributing to th
|
||||
|
||||
### Requirements
|
||||
|
||||
- Ruby 3.3.1
|
||||
- Ruby 3.3.4
|
||||
- PostgreSQL >9.3 (ideally, latest stable version)
|
||||
|
||||
After cloning the repo, the basic setup commands are:
|
||||
|
||||
BIN
app/assets/images/discord-icon.png
Normal file
BIN
app/assets/images/discord-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 932 B |
BIN
app/assets/images/github-icon.png
Normal file
BIN
app/assets/images/github-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 548 B |
@@ -4,11 +4,11 @@
|
||||
|
||||
/* Reset rules, default styles applied to plain HTML */
|
||||
@layer base {
|
||||
details > summary::-webkit-details-marker {
|
||||
details>summary::-webkit-details-marker {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
details > summary {
|
||||
details>summary {
|
||||
@apply list-none;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
@apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
||||
}
|
||||
|
||||
input:checked + label + .toggle-switch-dot {
|
||||
input:checked+label+.toggle-switch-dot {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,22 @@
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
@apply hidden absolute;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
@apply bg-gray-900 text-white hover:bg-gray-700;
|
||||
}
|
||||
|
||||
.btn--light {
|
||||
@apply bg-gray-25 border border-alpha-black-200 text-gray-900 hover:bg-gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small, single purpose classes that should take precedence over other styles */
|
||||
@@ -106,4 +122,4 @@
|
||||
.scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
app/controllers/account/cashes_controller.rb
Normal file
14
app/controllers/account/cashes_controller.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
class Account::CashesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
|
||||
def index
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
end
|
||||
@@ -1,52 +1,15 @@
|
||||
class Account::EntriesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: %i[ edit update show destroy ]
|
||||
|
||||
def transactions
|
||||
@transaction_entries = @account.entries.account_transactions.reverse_chronological
|
||||
end
|
||||
|
||||
def valuations
|
||||
@valuation_entries = @account.entries.account_valuations.reverse_chronological
|
||||
end
|
||||
|
||||
def trades
|
||||
@trades = @account.entries.account_trades.reverse_chronological
|
||||
end
|
||||
|
||||
def new
|
||||
@entry = @account.entries.build.tap do |entry|
|
||||
if params[:entryable_type]
|
||||
entry.entryable = Account::Entryable.from_type(params[:entryable_type]).new
|
||||
else
|
||||
entry.entryable = Account::Valuation.new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
@entry = @account.entries.build(entry_params_with_defaults(entry_params))
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
redirect_to account_path(@account), notice: t(".success", name: @entry.entryable_name_short.upcase_first)
|
||||
else
|
||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
||||
flash[:alert] = @entry.errors.full_messages.to_sentence
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
end
|
||||
before_action :set_entry, only: %i[edit update show destroy]
|
||||
|
||||
def edit
|
||||
render entryable_view_path(:edit)
|
||||
end
|
||||
|
||||
def update
|
||||
@entry.assign_attributes entry_params
|
||||
@entry.amount = amount if nature.present?
|
||||
@entry.save!
|
||||
@entry.update!(entry_params)
|
||||
@entry.sync_account_later
|
||||
|
||||
respond_to do |format|
|
||||
@@ -56,6 +19,7 @@ class Account::EntriesController < ApplicationController
|
||||
end
|
||||
|
||||
def show
|
||||
render entryable_view_path(:show)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -66,6 +30,10 @@ class Account::EntriesController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def entryable_view_path(action)
|
||||
@entry.entryable_type.underscore.pluralize + "/" + action.to_s
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
@@ -74,36 +42,7 @@ class Account::EntriesController < ApplicationController
|
||||
@entry = @account.entries.find(params[:id])
|
||||
end
|
||||
|
||||
def permitted_entryable_attributes
|
||||
entryable_type = @entry ? @entry.entryable_class.to_s : params[:account_entry][:entryable_type]
|
||||
|
||||
case entryable_type
|
||||
when "Account::Transaction"
|
||||
[ :id, :notes, :excluded, :category_id, :merchant_id, tag_ids: [] ]
|
||||
else
|
||||
[ :id ]
|
||||
end
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: permitted_entryable_attributes)
|
||||
end
|
||||
|
||||
def amount
|
||||
if nature.income?
|
||||
entry_params[:amount].to_d.abs * -1
|
||||
else
|
||||
entry_params[:amount].to_d.abs
|
||||
end
|
||||
end
|
||||
|
||||
def nature
|
||||
params[:account_entry][:nature].to_s.inquiry
|
||||
end
|
||||
|
||||
# entryable_type is required here because Rails expects both of these params in this exact order (potential upstream bug)
|
||||
def entry_params_with_defaults(params)
|
||||
params.with_defaults(entryable_type: params[:entryable_type], entryable_attributes: {})
|
||||
params.require(:account_entry).permit(:name, :date, :amount, :currency)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Account::HoldingsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_holding, only: :show
|
||||
|
||||
37
app/controllers/account/trades_controller.rb
Normal file
37
app/controllers/account/trades_controller.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
class Account::TradesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
|
||||
def new
|
||||
@entry = @account.entries.account_trades.new(entryable_attributes: {})
|
||||
end
|
||||
|
||||
def index
|
||||
@entries = @account.entries.reverse_chronological.where(entryable_type: %w[Account::Trade Account::Transaction])
|
||||
end
|
||||
|
||||
def create
|
||||
@builder = Account::EntryBuilder.new(entry_params)
|
||||
|
||||
if entry = @builder.save
|
||||
entry.sync_account_later
|
||||
redirect_to account_path(@account), notice: t(".success")
|
||||
else
|
||||
flash[:alert] = t(".failure")
|
||||
redirect_back_or_to account_path(@account)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(:type, :date, :qty, :ticker, :price, :amount, :currency, :transfer_account_id)
|
||||
.merge(account: @account)
|
||||
end
|
||||
end
|
||||
59
app/controllers/account/transactions_controller.rb
Normal file
59
app/controllers/account/transactions_controller.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
class Account::TransactionsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: :update
|
||||
|
||||
def index
|
||||
@pagy, @entries = pagy(
|
||||
@account.entries.account_transactions.reverse_chronological,
|
||||
limit: params[:per_page] || "10"
|
||||
)
|
||||
end
|
||||
|
||||
def update
|
||||
@entry.update!(entry_params)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = @account.entries.find(params[:id])
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(
|
||||
:name, :date, :amount, :currency, :entryable_type, :nature,
|
||||
entryable_attributes: [
|
||||
:id,
|
||||
:notes,
|
||||
:excluded,
|
||||
:category_id,
|
||||
:merchant_id,
|
||||
{ tag_ids: [] }
|
||||
]
|
||||
).tap do |permitted_params|
|
||||
nature = permitted_params.delete(:nature)
|
||||
|
||||
if permitted_params[:amount]
|
||||
amount_value = permitted_params[:amount].to_d
|
||||
|
||||
if nature == "income"
|
||||
amount_value *= -1
|
||||
end
|
||||
|
||||
permitted_params[:amount] = amount_value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class Account::TransfersController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_transfer, only: :destroy
|
||||
|
||||
@@ -40,6 +40,6 @@ class Account::TransfersController < ApplicationController
|
||||
end
|
||||
|
||||
def transfer_params
|
||||
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :currency, :date, :name)
|
||||
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name)
|
||||
end
|
||||
end
|
||||
|
||||
35
app/controllers/account/valuations_controller.rb
Normal file
35
app/controllers/account/valuations_controller.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class Account::ValuationsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
|
||||
def new
|
||||
@entry = @account.entries.account_valuations.new(entryable_attributes: {})
|
||||
end
|
||||
|
||||
def create
|
||||
@entry = @account.entries.account_valuations.new(entry_params.merge(entryable_attributes: {}))
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
redirect_back_or_to account_valuations_path(@account), notice: t(".success")
|
||||
else
|
||||
flash[:alert] = @entry.errors.full_messages.to_sentence
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
end
|
||||
|
||||
def index
|
||||
@entries = @account.entries.account_valuations.reverse_chronological
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:name, :date, :amount, :currency)
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,8 @@
|
||||
class AccountsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
include Filterable
|
||||
before_action :set_account, only: %i[ edit show destroy sync update ]
|
||||
before_action :set_account, only: %i[edit show destroy sync update]
|
||||
|
||||
def index
|
||||
@institutions = Current.family.institutions
|
||||
@@ -25,13 +25,16 @@ class AccountsController < ApplicationController
|
||||
def new
|
||||
@account = Account.new(accountable: Accountable.from_type(params[:type])&.new)
|
||||
|
||||
@account.accountable.address = Address.new if @account.accountable.is_a?(Property)
|
||||
|
||||
if params[:institution_id]
|
||||
@account.institution = Current.family.institutions.find_by(id: params[:institution_id])
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@balance_series = @account.series(period: @period)
|
||||
@series = @account.series(period: @period)
|
||||
@trend = @series.trend
|
||||
end
|
||||
|
||||
def edit
|
||||
|
||||
@@ -4,4 +4,12 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
allow_browser versions: :modern
|
||||
|
||||
private
|
||||
|
||||
def with_sidebar
|
||||
return "turbo_rails/frame" if turbo_frame_request?
|
||||
|
||||
"with_sidebar"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class CategoriesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_category, only: %i[ edit update ]
|
||||
before_action :set_category, only: %i[edit update]
|
||||
before_action :set_transaction, only: :create
|
||||
|
||||
def index
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Category::DeletionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_category
|
||||
before_action :set_replacement_category, only: :create
|
||||
|
||||
@@ -6,17 +6,17 @@ class Category::DropdownsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
def set_from_params
|
||||
if params[:category_id]
|
||||
@selected_category = categories_scope.find(params[:category_id])
|
||||
def set_from_params
|
||||
if params[:category_id]
|
||||
@selected_category = categories_scope.find(params[:category_id])
|
||||
end
|
||||
|
||||
if params[:transaction_id]
|
||||
@transaction = Current.family.transactions.find(params[:transaction_id])
|
||||
end
|
||||
end
|
||||
|
||||
if params[:transaction_id]
|
||||
@transaction = Current.family.transactions.find(params[:transaction_id])
|
||||
def categories_scope
|
||||
Current.family.categories.alphabetically
|
||||
end
|
||||
end
|
||||
|
||||
def categories_scope
|
||||
Current.family.categories.alphabetically
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,27 +13,35 @@ module Authentication
|
||||
|
||||
private
|
||||
|
||||
def authenticate_user!
|
||||
if user = User.find_by(id: session[:user_id])
|
||||
Current.user = user
|
||||
else
|
||||
redirect_to new_session_url
|
||||
def authenticate_user!
|
||||
if user = User.find_by(id: session[:user_id])
|
||||
Current.user = user
|
||||
else
|
||||
if self_hosted_first_login?
|
||||
redirect_to new_registration_url
|
||||
else
|
||||
redirect_to new_session_url
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def login(user)
|
||||
Current.user = user
|
||||
reset_session
|
||||
session[:user_id] = user.id
|
||||
set_last_login_at
|
||||
end
|
||||
def login(user)
|
||||
Current.user = user
|
||||
reset_session
|
||||
session[:user_id] = user.id
|
||||
set_last_login_at
|
||||
end
|
||||
|
||||
def logout
|
||||
Current.user = nil
|
||||
reset_session
|
||||
end
|
||||
def logout
|
||||
Current.user = nil
|
||||
reset_session
|
||||
end
|
||||
|
||||
def set_last_login_at
|
||||
Current.user.update(last_login_at: DateTime.now)
|
||||
end
|
||||
def set_last_login_at
|
||||
Current.user.update(last_login_at: DateTime.now)
|
||||
end
|
||||
|
||||
def self_hosted_first_login?
|
||||
Rails.application.config.app_mode.self_hosted? && User.count.zero?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
module Filterable
|
||||
extend ActiveSupport::Concern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_period
|
||||
end
|
||||
included do
|
||||
before_action :set_period
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ module Invitable
|
||||
|
||||
private
|
||||
def invite_code_required?
|
||||
ENV["REQUIRE_INVITE_CODE"] == "true"
|
||||
self_hosted? ? Setting.require_invite_for_signup : ENV["REQUIRE_INVITE_CODE"] == "true"
|
||||
end
|
||||
|
||||
def self_hosted?
|
||||
Rails.application.config.app_mode.self_hosted?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,11 +2,15 @@ module SelfHostable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
helper_method :self_hosted?
|
||||
helper_method :self_hosted?, :self_hosted_first_login?
|
||||
end
|
||||
|
||||
private
|
||||
def self_hosted?
|
||||
Rails.configuration.app_mode.self_hosted?
|
||||
end
|
||||
|
||||
def self_hosted_first_login?
|
||||
self_hosted? && User.count.zero?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Help::ArticlesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
def show
|
||||
@article = Help::Article.find(params[:id])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require "ostruct"
|
||||
|
||||
class ImportsController < ApplicationController
|
||||
before_action :set_import, except: %i[ index new create ]
|
||||
before_action :set_import, except: %i[index new create]
|
||||
|
||||
def index
|
||||
@imports = Current.family.imports
|
||||
@@ -18,14 +18,14 @@ class ImportsController < ApplicationController
|
||||
|
||||
def update
|
||||
account = Current.family.accounts.find(params[:import][:account_id])
|
||||
@import.update! account: account, col_sep: params[:import][:col_sep]
|
||||
|
||||
@import.update! account: account
|
||||
redirect_to load_import_path(@import), notice: t(".import_updated")
|
||||
end
|
||||
|
||||
def create
|
||||
account = Current.family.accounts.find(params[:import][:account_id])
|
||||
@import = Import.create!(account: account)
|
||||
@import = Import.create! account: account, col_sep: params[:import][:col_sep]
|
||||
|
||||
redirect_to load_import_path(@import), notice: t(".import_created")
|
||||
end
|
||||
@@ -40,7 +40,7 @@ class ImportsController < ApplicationController
|
||||
|
||||
def upload_csv
|
||||
begin
|
||||
@import.raw_csv_str = import_params[:raw_csv_str].read
|
||||
@import.raw_file_str = import_params[:raw_file_str].read
|
||||
rescue NoMethodError
|
||||
flash.now[:alert] = "Please select a file to upload"
|
||||
render :load, status: :unprocessable_entity and return
|
||||
@@ -113,6 +113,6 @@ class ImportsController < ApplicationController
|
||||
end
|
||||
|
||||
def import_params(permitted_mappings = nil)
|
||||
params.require(:import).permit(:raw_csv_str, column_mappings: permitted_mappings, csv_update: [ :row_idx, :col_idx, :value ])
|
||||
params.require(:import).permit(:raw_file_str, column_mappings: permitted_mappings, csv_update: [ :row_idx, :col_idx, :value ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class InstitutionsController < ApplicationController
|
||||
before_action :set_institution, except: %i[ new create ]
|
||||
before_action :set_institution, except: %i[new create]
|
||||
|
||||
def new
|
||||
@institution = Institution.new
|
||||
@@ -23,6 +23,11 @@ class InstitutionsController < ApplicationController
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def sync
|
||||
@institution.sync
|
||||
redirect_back_or_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def institution_params
|
||||
|
||||
10
app/controllers/invite_codes_controller.rb
Normal file
10
app/controllers/invite_codes_controller.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class InviteCodesController < ApplicationController
|
||||
def index
|
||||
@invite_codes = InviteCode.all
|
||||
end
|
||||
|
||||
def create
|
||||
InviteCode.generate!
|
||||
redirect_back_or_to invite_codes_path, notice: "Code generated"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
class Issue::ExchangeRateProviderMissingsController < ApplicationController
|
||||
before_action :set_issue, only: :update
|
||||
|
||||
def update
|
||||
Setting.synth_api_key = exchange_rate_params[:synth_api_key]
|
||||
@issue.issuable.sync_later
|
||||
redirect_back_or_to account_path(@issue.issuable)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_issue
|
||||
@issue = Current.family.issues.find(params[:id])
|
||||
end
|
||||
|
||||
def exchange_rate_params
|
||||
params.require(:issue_exchange_rate_provider_missing).permit(:synth_api_key)
|
||||
end
|
||||
end
|
||||
13
app/controllers/issues_controller.rb
Normal file
13
app/controllers/issues_controller.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class IssuesController < ApplicationController
|
||||
before_action :set_issue, only: :show
|
||||
|
||||
def show
|
||||
render template: "#{@issue.class.name.underscore.pluralize}/show", layout: "issues"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_issue
|
||||
@issue = Current.family.issues.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
class MerchantsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_merchant, only: %i[ edit update destroy ]
|
||||
before_action :set_merchant, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@merchants = Current.family.merchants.alphabetically
|
||||
@@ -31,11 +31,11 @@ class MerchantsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def set_merchant
|
||||
@merchant = Current.family.merchants.find(params[:id])
|
||||
end
|
||||
def set_merchant
|
||||
@merchant = Current.family.merchants.find(params[:id])
|
||||
end
|
||||
|
||||
def merchant_params
|
||||
params.require(:merchant).permit(:name, :color)
|
||||
end
|
||||
def merchant_params
|
||||
params.require(:merchant).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class PagesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
include Filterable
|
||||
|
||||
@@ -31,7 +31,7 @@ class PagesController < ApplicationController
|
||||
end
|
||||
|
||||
def changelog
|
||||
@releases_notes = Provider::Github.new.fetch_latest_releases_notes
|
||||
@release_notes = Provider::Github.new.fetch_latest_release_notes
|
||||
end
|
||||
|
||||
def feedback
|
||||
|
||||
@@ -3,7 +3,7 @@ class PasswordResetsController < ApplicationController
|
||||
|
||||
layout "auth"
|
||||
|
||||
before_action :set_user_by_token, only: %i[ edit update ]
|
||||
before_action :set_user_by_token, only: %i[edit update]
|
||||
|
||||
def new
|
||||
end
|
||||
@@ -33,12 +33,12 @@ class PasswordResetsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def set_user_by_token
|
||||
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||
redirect_to new_password_reset_path, alert: t("password_resets.update.invalid_token") unless @user.present?
|
||||
end
|
||||
def set_user_by_token
|
||||
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||
redirect_to new_password_reset_path, alert: t("password_resets.update.invalid_token") unless @user.present?
|
||||
end
|
||||
|
||||
def password_params
|
||||
params.require(:user).permit(:password, :password_confirmation)
|
||||
end
|
||||
def password_params
|
||||
params.require(:user).permit(:password, :password_confirmation)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ class PasswordsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def password_params
|
||||
params.require(:user).permit(:password, :password_confirmation, :password_challenge).with_defaults(password_challenge: "")
|
||||
end
|
||||
def password_params
|
||||
params.require(:user).permit(:password, :password_confirmation, :password_challenge).with_defaults(password_challenge: "")
|
||||
end
|
||||
end
|
||||
|
||||
41
app/controllers/properties_controller.rb
Normal file
41
app/controllers/properties_controller.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
class PropertiesController < ApplicationController
|
||||
before_action :set_account, only: :update
|
||||
|
||||
def create
|
||||
account = Current.family
|
||||
.accounts
|
||||
.create_with_optional_start_balance! \
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
start_date: account_params[:start_date],
|
||||
start_balance: account_params[:start_balance]
|
||||
|
||||
account.sync_later
|
||||
redirect_to account, notice: t(".success")
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update!(account_params)
|
||||
@account.sync_later
|
||||
redirect_to @account, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account)
|
||||
.permit(
|
||||
:name, :balance, :start_date, :start_balance, :currency, :accountable_type,
|
||||
accountable_attributes: [
|
||||
:id,
|
||||
:year_built,
|
||||
:area_unit,
|
||||
:area_value,
|
||||
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -28,17 +28,17 @@ class RegistrationsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = User.new user_params.except(:invite_code)
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code)
|
||||
end
|
||||
|
||||
def claim_invite_code
|
||||
unless InviteCode.claim! params[:user][:invite_code]
|
||||
redirect_to new_registration_path, alert: t("registrations.create.invalid_invite_code")
|
||||
def set_user
|
||||
@user = User.new user_params.except(:invite_code)
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code)
|
||||
end
|
||||
|
||||
def claim_invite_code
|
||||
unless InviteCode.claim! params[:user][:invite_code]
|
||||
redirect_to new_registration_path, alert: t("registrations.create.invalid_invite_code")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
class Settings::BillingsController < SettingsController
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
end
|
||||
end
|
||||
@@ -55,13 +55,13 @@ class Settings::HostingsController < SettingsController
|
||||
end
|
||||
|
||||
def hosting_params
|
||||
permitted_params = params.require(:setting).permit(:render_deploy_hook, :upgrades_mode, :email_sender, :app_domain, :smtp_host, :smtp_port, :smtp_username, :smtp_password)
|
||||
permitted_params = params.require(:setting).permit(:render_deploy_hook, :upgrades_mode, :email_sender, :app_domain, :smtp_host, :smtp_port, :smtp_username, :smtp_password, :require_invite_for_signup)
|
||||
|
||||
result = {}
|
||||
result[:upgrades_mode] = permitted_params[:upgrades_mode] == "manual" ? "manual" : "auto" if permitted_params.key?(:upgrades_mode)
|
||||
result[:render_deploy_hook] = permitted_params[:render_deploy_hook] if permitted_params.key?(:render_deploy_hook)
|
||||
result[:upgrades_target] = permitted_params[:upgrades_mode] unless permitted_params[:upgrades_mode] == "manual" if permitted_params.key?(:upgrades_mode)
|
||||
result.merge!(permitted_params.slice(:email_sender, :app_domain, :smtp_host, :smtp_port, :smtp_username, :smtp_password))
|
||||
result.merge!(permitted_params.slice(:email_sender, :app_domain, :smtp_host, :smtp_port, :smtp_username, :smtp_password, :require_invite_for_signup))
|
||||
result
|
||||
end
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
class Settings::NotificationsController < SettingsController
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
end
|
||||
end
|
||||
@@ -17,7 +17,7 @@ class Settings::ProfilesController < SettingsController
|
||||
if Current.user.update(user_params_with_family)
|
||||
redirect_to settings_profile_path, notice: t(".success")
|
||||
else
|
||||
redirect_to settings_profile_path, alert: t(".file_size_error")
|
||||
redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence
|
||||
end
|
||||
end
|
||||
|
||||
@@ -32,8 +32,8 @@ class Settings::ProfilesController < SettingsController
|
||||
|
||||
private
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:first_name, :last_name, :profile_image,
|
||||
family_attributes: [ :name, :id ])
|
||||
end
|
||||
def user_params
|
||||
params.require(:user).permit(:first_name, :last_name, :profile_image,
|
||||
family_attributes: [ :name, :id ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
class Settings::SecuritiesController < SettingsController
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
end
|
||||
end
|
||||
@@ -1,3 +1,3 @@
|
||||
class SettingsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Tag::DeletionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_tag
|
||||
before_action :set_replacement_tag, only: :create
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class TagsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_tag, only: %i[ edit update ]
|
||||
before_action :set_tag, only: %i[edit update]
|
||||
|
||||
def index
|
||||
@tags = Current.family.tags.alphabetically
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class TransactionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
layout :with_sidebar
|
||||
|
||||
def index
|
||||
@q = search_params
|
||||
|
||||
42
app/controllers/vehicles_controller.rb
Normal file
42
app/controllers/vehicles_controller.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class VehiclesController < ApplicationController
|
||||
before_action :set_account, only: :update
|
||||
|
||||
def create
|
||||
account = Current.family
|
||||
.accounts
|
||||
.create_with_optional_start_balance! \
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
start_date: account_params[:start_date],
|
||||
start_balance: account_params[:start_balance]
|
||||
|
||||
account.sync_later
|
||||
redirect_to account, notice: t(".success")
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update!(account_params)
|
||||
@account.sync_later
|
||||
redirect_to @account, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account)
|
||||
.permit(
|
||||
:name, :balance, :start_date, :start_balance, :currency, :accountable_type,
|
||||
accountable_attributes: [
|
||||
:id,
|
||||
:make,
|
||||
:model,
|
||||
:year,
|
||||
:mileage_value,
|
||||
:mileage_unit
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
13
app/helpers/account/cashes_helper.rb
Normal file
13
app/helpers/account/cashes_helper.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Account::CashesHelper
|
||||
def brokerage_cash(account)
|
||||
currency = Money::Currency.new(account.currency)
|
||||
|
||||
account.holdings.build \
|
||||
date: Date.current,
|
||||
qty: account.balance,
|
||||
price: 1,
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
security: Security.new(ticker: currency.iso_code, name: currency.name)
|
||||
end
|
||||
end
|
||||
@@ -30,6 +30,28 @@ module Account::EntriesHelper
|
||||
mixed_hex_styles(color)
|
||||
end
|
||||
|
||||
def entry_name(entry)
|
||||
if entry.account_trade?
|
||||
trade = entry.account_trade
|
||||
prefix = trade.sell? ? "Sell " : "Buy "
|
||||
generated = prefix + "#{trade.qty.abs} shares of #{trade.security.ticker}"
|
||||
name = entry.name || generated
|
||||
name
|
||||
else
|
||||
entry.name || "Transaction"
|
||||
end
|
||||
end
|
||||
|
||||
def entries_by_date(entries, selectable: true)
|
||||
entries.group_by(&:date).map do |date, grouped_entries|
|
||||
content = capture do
|
||||
yield grouped_entries
|
||||
end
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable: }
|
||||
end.join.html_safe
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permitted_entryable_key(entry)
|
||||
|
||||
@@ -23,13 +23,40 @@ module AccountsHelper
|
||||
class_mapping(accountable_type)[:hex]
|
||||
end
|
||||
|
||||
def account_tabs(account)
|
||||
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), content_path: account_holdings_path(account) }
|
||||
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), content_path: valuation_account_entries_path(account) }
|
||||
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: transaction_account_entries_path(account) }
|
||||
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: trade_account_entries_path(account) }
|
||||
# Eventually, we'll have an accountable form for each type of accountable, so
|
||||
# this helper is a convenience for now to reuse common logic in the accounts controller
|
||||
def new_account_form_url(account)
|
||||
case account.accountable_type
|
||||
when "Property"
|
||||
properties_path
|
||||
when "Vehicle"
|
||||
vehicles_path
|
||||
else
|
||||
accounts_path
|
||||
end
|
||||
end
|
||||
|
||||
return [ holdings_tab, trades_tab ] if account.investment?
|
||||
def edit_account_form_url(account)
|
||||
case account.accountable_type
|
||||
when "Property"
|
||||
property_path(account)
|
||||
when "Vehicle"
|
||||
vehicle_path(account)
|
||||
else
|
||||
account_path(account)
|
||||
end
|
||||
end
|
||||
|
||||
def account_tabs(account)
|
||||
overview_tab = { key: "overview", label: t("accounts.show.overview"), path: account_path(account, tab: "overview"), partial_path: "accounts/overview" }
|
||||
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), route: account_holdings_path(account) }
|
||||
cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), route: account_cashes_path(account) }
|
||||
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), route: account_valuations_path(account) }
|
||||
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), route: account_transactions_path(account) }
|
||||
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), route: account_trades_path(account) }
|
||||
|
||||
return [ overview_tab, value_tab ] if account.property? || account.vehicle?
|
||||
return [ holdings_tab, cash_tab, trades_tab ] if account.investment?
|
||||
|
||||
[ value_tab, transactions_tab ]
|
||||
end
|
||||
@@ -42,6 +69,11 @@ module AccountsHelper
|
||||
tab || available_tabs.first
|
||||
end
|
||||
|
||||
def account_groups(period: nil)
|
||||
assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
|
||||
[ assets.children, liabilities.children ].flatten
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def class_mapping(accountable_type)
|
||||
|
||||
@@ -57,11 +57,6 @@ module ApplicationHelper
|
||||
render partial: "shared/drawer", locals: { content: content }
|
||||
end
|
||||
|
||||
def account_groups(period: nil)
|
||||
assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
|
||||
[ assets.children, liabilities.children ].flatten
|
||||
end
|
||||
|
||||
def sidebar_link_to(name, path, options = {})
|
||||
is_current = current_page?(path) || (request.path.start_with?(path) && path != "/")
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@ module FormsHelper
|
||||
form_with(**options, &block)
|
||||
end
|
||||
|
||||
def modal_form_wrapper(title:, subtitle: nil, &block)
|
||||
content = capture &block
|
||||
|
||||
render partial: "shared/modal_form", locals: { title:, subtitle:, content: }
|
||||
end
|
||||
|
||||
def form_field_tag(options = {}, &block)
|
||||
options[:class] = [ "form-field", options[:class] ].compact.join(" ")
|
||||
tag.div(**options, &block)
|
||||
@@ -23,17 +29,17 @@ module FormsHelper
|
||||
|
||||
def money_with_currency_field(form, money_method, options = {})
|
||||
render partial: "shared/money_field", locals: {
|
||||
form: form,
|
||||
money_method: money_method,
|
||||
form: form,
|
||||
money_method: money_method,
|
||||
default_currency: options[:default_currency] || "USD",
|
||||
disable_currency: options[:disable_currency] || false,
|
||||
hide_currency: options[:hide_currency] || false,
|
||||
label: options[:label] || "Amount"
|
||||
label: options[:label] || "Amount"
|
||||
}
|
||||
end
|
||||
|
||||
def money_field(form, method, options = {})
|
||||
value = form.object.send(method)
|
||||
value = form.object ? form.object.send(method) : nil
|
||||
|
||||
currency = value&.currency || Money::Currency.new(options[:default_currency] || "USD")
|
||||
|
||||
@@ -42,10 +48,10 @@ module FormsHelper
|
||||
|
||||
money_options = {
|
||||
value: value&.amount,
|
||||
placeholder: 100,
|
||||
min: -99999999999999,
|
||||
max: 99999999999999,
|
||||
step: currency.step
|
||||
placeholder: "100",
|
||||
min: -99999999999999,
|
||||
max: 99999999999999,
|
||||
step: currency.step
|
||||
}
|
||||
|
||||
merged_options = options.merge(money_options)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
module MenusHelper
|
||||
def contextual_menu(&block)
|
||||
tag.div class: "relative cursor-pointer", data: { controller: "menu" } do
|
||||
tag.div data: { controller: "menu" } do
|
||||
concat contextual_menu_icon
|
||||
concat contextual_menu_content(&block)
|
||||
end
|
||||
end
|
||||
|
||||
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: nil)
|
||||
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal)
|
||||
link_to url, class: "flex items-center rounded-lg text-gray-900 hover:bg-gray-50 py-2 px-3 gap-2", data: { turbo_frame: } do
|
||||
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-gray-500"))
|
||||
concat(tag.span(label, class: "text-sm"))
|
||||
@@ -25,13 +25,14 @@ module MenusHelper
|
||||
|
||||
private
|
||||
def contextual_menu_icon
|
||||
tag.button class: "flex hover:bg-gray-100 p-2 rounded", data: { menu_target: "button" } do
|
||||
tag.button class: "flex hover:bg-gray-100 p-2 rounded cursor-pointer", data: { menu_target: "button" } do
|
||||
lucide_icon "more-horizontal", class: "w-5 h-5 text-gray-500"
|
||||
end
|
||||
end
|
||||
|
||||
def contextual_menu_content(&block)
|
||||
tag.div class: "absolute z-10 top-10 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden", data: { menu_target: "content" } do
|
||||
tag.div class: "z-50 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden",
|
||||
data: { menu_target: "content" } do
|
||||
capture(&block)
|
||||
end
|
||||
end
|
||||
|
||||
2
app/helpers/properties_helper.rb
Normal file
2
app/helpers/properties_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module PropertiesHelper
|
||||
end
|
||||
@@ -1,14 +1,46 @@
|
||||
module SettingsHelper
|
||||
def next_setting(title, path)
|
||||
render partial: "settings/nav_link_large", locals: { path: path, direction: "next", title: title }
|
||||
end
|
||||
SETTINGS_ORDER = [
|
||||
{ name: I18n.t("settings.nav.profile_label"), path: :settings_profile_path },
|
||||
{ name: I18n.t("settings.nav.preferences_label"), path: :settings_preferences_path },
|
||||
{ name: I18n.t("settings.nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
|
||||
{ name: I18n.t("settings.nav.accounts_label"), path: :accounts_path },
|
||||
{ name: I18n.t("settings.nav.tags_label"), path: :tags_path },
|
||||
{ name: I18n.t("settings.nav.categories_label"), path: :categories_path },
|
||||
{ name: I18n.t("settings.nav.merchants_label"), path: :merchants_path },
|
||||
{ name: I18n.t("settings.nav.imports_label"), path: :imports_path },
|
||||
{ name: I18n.t("settings.nav.whats_new_label"), path: :changelog_path },
|
||||
{ name: I18n.t("settings.nav.feedback_label"), path: :feedback_path }
|
||||
]
|
||||
|
||||
def previous_setting(title, path)
|
||||
render partial: "settings/nav_link_large", locals: { path: path, direction: "previous", title: title }
|
||||
def adjacent_setting(current_path, offset)
|
||||
visible_settings = SETTINGS_ORDER.select { |setting| setting[:condition].nil? || send(setting[:condition]) }
|
||||
current_index = visible_settings.index { |setting| send(setting[:path]) == current_path }
|
||||
return nil unless current_index
|
||||
|
||||
adjacent_index = current_index + offset
|
||||
return nil if adjacent_index < 0 || adjacent_index >= visible_settings.size
|
||||
|
||||
adjacent = visible_settings[adjacent_index]
|
||||
|
||||
render partial: "settings/nav_link_large", locals: {
|
||||
path: send(adjacent[:path]),
|
||||
direction: offset > 0 ? "next" : "previous",
|
||||
title: adjacent[:name]
|
||||
}
|
||||
end
|
||||
|
||||
def settings_section(title:, subtitle: nil, &block)
|
||||
content = capture(&block)
|
||||
render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content }
|
||||
end
|
||||
|
||||
def settings_nav_footer
|
||||
previous_setting = adjacent_setting(request.path, -1)
|
||||
next_setting = adjacent_setting(request.path, 1)
|
||||
|
||||
content_tag :div, class: "flex justify-between gap-4" do
|
||||
concat(previous_setting)
|
||||
concat(next_setting)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
2
app/helpers/vehicles_helper.rb
Normal file
2
app/helpers/vehicles_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module VehiclesHelper
|
||||
end
|
||||
28
app/javascript/controllers/clipboard_controller.js
Normal file
28
app/javascript/controllers/clipboard_controller.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["source", "iconDefault", "iconSuccess"]
|
||||
|
||||
copy(event) {
|
||||
event.preventDefault();
|
||||
if (this.sourceTarget && this.sourceTarget.textContent) {
|
||||
navigator.clipboard.writeText(this.sourceTarget.textContent)
|
||||
.then(() => {
|
||||
this.showSuccess();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to copy text: ', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
showSuccess() {
|
||||
this.iconDefaultTarget.classList.add('hidden');
|
||||
this.iconSuccessTarget.classList.remove('hidden');
|
||||
setTimeout(() => {
|
||||
this.iconDefaultTarget.classList.remove('hidden');
|
||||
this.iconSuccessTarget.classList.add('hidden');
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,29 @@
|
||||
import {Controller} from "@hotwired/stimulus";
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="merchant-avatar"
|
||||
// Connects to data-controller="color-avatar"
|
||||
// Used by the transaction merchant form to show a preview of what the avatar will look like
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"name",
|
||||
"color",
|
||||
"avatar"
|
||||
];
|
||||
|
||||
connect() {
|
||||
this.nameTarget.addEventListener("input", this.handleNameChange);
|
||||
this.colorTarget.addEventListener("input", this.handleColorChange);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.nameTarget.removeEventListener("input", this.handleNameChange);
|
||||
this.colorTarget.removeEventListener("input", this.handleColorChange);
|
||||
}
|
||||
|
||||
handleNameChange = (e) => {
|
||||
this.avatarTarget.textContent = (e.currentTarget.value?.[0] || "?").toUpperCase();
|
||||
}
|
||||
|
||||
handleColorChange = (e) => {
|
||||
handleColorChange(e) {
|
||||
const color = e.currentTarget.value;
|
||||
this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 5%, white)`;
|
||||
this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`;
|
||||
this.avatarTarget.style.color = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "preview", "submit", "filename", "filesize"]
|
||||
static values = {
|
||||
acceptedTypes: Array, // ["text/csv", "application/csv", ".csv"]
|
||||
acceptedExtension: String, // "csv"
|
||||
unacceptableTypeLabel: String, // "Only CSV files are allowed."
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.submitTarget.disabled = true
|
||||
@@ -30,12 +35,12 @@ export default class extends Controller {
|
||||
event.currentTarget.classList.remove("bg-gray-100")
|
||||
|
||||
const file = event.dataTransfer.files[0]
|
||||
if (file && this._isCSVFile(file)) {
|
||||
if (file && this._formatAcceptable(file)) {
|
||||
this._setFileInput(file);
|
||||
this._fileAdded(file)
|
||||
} else {
|
||||
this.previewTarget.classList.add("text-red-500")
|
||||
this.previewTarget.textContent = "Only CSV files are allowed."
|
||||
this.previewTarget.textContent = this.unacceptableTypeLabelValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +62,7 @@ export default class extends Controller {
|
||||
if (file) {
|
||||
if (file.size > fileSizeLimit) {
|
||||
this.previewTarget.classList.add("text-red-500")
|
||||
this.previewTarget.textContent = "File size exceeds the limit of 5MB"
|
||||
this.previewTarget.textContent = this.unacceptableTypeLabelValue
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,10 +85,9 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
_isCSVFile(file) {
|
||||
const acceptedTypes = ["text/csv", "application/csv", ".csv"]
|
||||
_formatAcceptable(file) {
|
||||
const extension = file.name.split('.').pop().toLowerCase()
|
||||
return acceptedTypes.includes(file.type) || extension === "csv"
|
||||
return this.acceptedTypesValue.includes(file.type) || extension === this.acceptedExtensionValue
|
||||
}
|
||||
|
||||
_setFileInput(file) {
|
||||
@@ -1,61 +1,57 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';
|
||||
|
||||
/**
|
||||
* A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms.
|
||||
*
|
||||
* - If you need a form-enabled "select" element, use the "listbox" controller instead.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"button",
|
||||
"content",
|
||||
"submenu",
|
||||
"submenuButton",
|
||||
"submenuContent",
|
||||
];
|
||||
static targets = ["button", "content"];
|
||||
|
||||
static values = {
|
||||
show: { type: Boolean, default: false },
|
||||
showSubmenu: { type: Boolean, default: false },
|
||||
show: Boolean,
|
||||
placement: { type: String, default: "bottom-end" },
|
||||
offset: { type: Number, default: 6 },
|
||||
};
|
||||
|
||||
initialize() {
|
||||
connect() {
|
||||
this.show = this.showValue;
|
||||
this.showSubmenu = this.showSubmenuValue;
|
||||
this.boundUpdate = this.update.bind(this);
|
||||
this.addEventListeners();
|
||||
this.startAutoUpdate();
|
||||
}
|
||||
|
||||
connect() {
|
||||
disconnect() {
|
||||
this.removeEventListeners();
|
||||
this.stopAutoUpdate();
|
||||
this.close();
|
||||
}
|
||||
|
||||
addEventListeners() {
|
||||
this.buttonTarget.addEventListener("click", this.toggle);
|
||||
this.element.addEventListener("keydown", this.handleKeydown);
|
||||
document.addEventListener("click", this.handleOutsideClick);
|
||||
document.addEventListener("turbo:load", this.handleTurboLoad);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener("keydown", this.handleKeydown);
|
||||
removeEventListeners() {
|
||||
this.buttonTarget.removeEventListener("click", this.toggle);
|
||||
this.element.removeEventListener("keydown", this.handleKeydown);
|
||||
document.removeEventListener("click", this.handleOutsideClick);
|
||||
document.removeEventListener("turbo:load", this.handleTurboLoad);
|
||||
this.close();
|
||||
}
|
||||
|
||||
// If turbo reloads, we maintain the state of the menu
|
||||
handleTurboLoad = () => {
|
||||
if (!this.show) this.close();
|
||||
};
|
||||
|
||||
handleOutsideClick = (event) => {
|
||||
if (this.show && !this.element.contains(event.target)) {
|
||||
this.close();
|
||||
}
|
||||
if (this.show && !this.element.contains(event.target)) this.close();
|
||||
};
|
||||
|
||||
handleKeydown = (event) => {
|
||||
switch (event.key) {
|
||||
case "Escape":
|
||||
this.close();
|
||||
this.buttonTarget.focus(); // Bring focus back to the button
|
||||
break;
|
||||
if (event.key === "Escape") {
|
||||
this.close();
|
||||
this.buttonTarget.focus();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,6 +59,7 @@ export default class extends Controller {
|
||||
this.show = !this.show;
|
||||
this.contentTarget.classList.toggle("hidden", !this.show);
|
||||
if (this.show) {
|
||||
this.update();
|
||||
this.focusFirstElement();
|
||||
}
|
||||
};
|
||||
@@ -73,12 +70,40 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
focusFirstElement() {
|
||||
const focusableElements =
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const firstFocusableElement =
|
||||
this.contentTarget.querySelectorAll(focusableElements)[0];
|
||||
const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const firstFocusableElement = this.contentTarget.querySelectorAll(focusableElements)[0];
|
||||
if (firstFocusableElement) {
|
||||
firstFocusableElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
startAutoUpdate() {
|
||||
if (!this._cleanup) {
|
||||
this._cleanup = autoUpdate(this.buttonTarget, this.contentTarget, this.boundUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
stopAutoUpdate() {
|
||||
if (this._cleanup) {
|
||||
this._cleanup();
|
||||
this._cleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
computePosition(this.buttonTarget, this.contentTarget, {
|
||||
placement: this.placementValue,
|
||||
middleware: [
|
||||
offset(this.offsetValue),
|
||||
flip(),
|
||||
shift({ padding: 5 })
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
Object.assign(this.contentTarget.style, {
|
||||
position: 'fixed',
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
83
app/javascript/controllers/tooltip_controller.js
Normal file
83
app/javascript/controllers/tooltip_controller.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import {
|
||||
computePosition,
|
||||
flip,
|
||||
shift,
|
||||
offset,
|
||||
autoUpdate
|
||||
} from '@floating-ui/dom';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["tooltip"];
|
||||
static values = {
|
||||
placement: { type: String, default: "top" },
|
||||
offset: { type: Number, default: 10 },
|
||||
crossAxis: { type: Number, default: 0 },
|
||||
alignmentAxis: { type: Number, default: null },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this._cleanup = null;
|
||||
this.boundUpdate = this.update.bind(this);
|
||||
this.startAutoUpdate();
|
||||
this.addEventListeners();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.removeEventListeners();
|
||||
this.stopAutoUpdate();
|
||||
}
|
||||
|
||||
addEventListeners() {
|
||||
this.element.addEventListener("mouseenter", this.show);
|
||||
this.element.addEventListener("mouseleave", this.hide);
|
||||
}
|
||||
|
||||
removeEventListeners() {
|
||||
this.element.removeEventListener("mouseenter", this.show);
|
||||
this.element.removeEventListener("mouseleave", this.hide);
|
||||
}
|
||||
|
||||
show = () => {
|
||||
this.tooltipTarget.style.display = 'block';
|
||||
this.update(); // Ensure immediate update when shown
|
||||
}
|
||||
|
||||
hide = () => {
|
||||
this.tooltipTarget.style.display = 'none';
|
||||
}
|
||||
|
||||
startAutoUpdate() {
|
||||
if (!this._cleanup) {
|
||||
this._cleanup = autoUpdate(
|
||||
this.element,
|
||||
this.tooltipTarget,
|
||||
this.boundUpdate
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
stopAutoUpdate() {
|
||||
if (this._cleanup) {
|
||||
this._cleanup();
|
||||
this._cleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
// Update position even if not visible, to ensure correct positioning when shown
|
||||
computePosition(this.element, this.tooltipTarget, {
|
||||
placement: this.placementValue,
|
||||
middleware: [
|
||||
offset({ mainAxis: this.offsetValue, crossAxis: this.crossAxisValue, alignmentAxis: this.alignmentAxisValue }),
|
||||
flip(),
|
||||
shift({ padding: 5 })
|
||||
],
|
||||
}).then(({ x, y, placement, middlewareData }) => {
|
||||
Object.assign(this.tooltipTarget.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
64
app/javascript/controllers/trade_form_controller.js
Normal file
64
app/javascript/controllers/trade_form_controller.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import {Controller} from "@hotwired/stimulus"
|
||||
|
||||
const TRADE_TYPES = {
|
||||
BUY: "buy",
|
||||
SELL: "sell",
|
||||
TRANSFER_IN: "transfer_in",
|
||||
TRANSFER_OUT: "transfer_out",
|
||||
INTEREST: "interest"
|
||||
}
|
||||
|
||||
const FIELD_VISIBILITY = {
|
||||
[TRADE_TYPES.BUY]: {ticker: true, qty: true, price: true},
|
||||
[TRADE_TYPES.SELL]: {ticker: true, qty: true, price: true},
|
||||
[TRADE_TYPES.TRANSFER_IN]: {amount: true, transferAccount: true},
|
||||
[TRADE_TYPES.TRANSFER_OUT]: {amount: true, transferAccount: true},
|
||||
[TRADE_TYPES.INTEREST]: {amount: true}
|
||||
}
|
||||
|
||||
// Connects to data-controller="trade-form"
|
||||
export default class extends Controller {
|
||||
static targets = ["typeInput", "tickerInput", "amountInput", "transferAccountInput", "qtyInput", "priceInput"]
|
||||
|
||||
connect() {
|
||||
this.handleTypeChange = this.handleTypeChange.bind(this)
|
||||
this.typeInputTarget.addEventListener("change", this.handleTypeChange)
|
||||
this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.typeInputTarget.removeEventListener("change", this.handleTypeChange)
|
||||
}
|
||||
|
||||
handleTypeChange(event) {
|
||||
this.updateFields(event.target.value)
|
||||
}
|
||||
|
||||
updateFields(type) {
|
||||
const visibleFields = FIELD_VISIBILITY[type] || {}
|
||||
|
||||
Object.entries(this.fieldTargets).forEach(([field, target]) => {
|
||||
const isVisible = visibleFields[field] || false
|
||||
|
||||
// Update visibility
|
||||
target.hidden = !isVisible
|
||||
|
||||
// Update required status based on visibility
|
||||
if (isVisible) {
|
||||
target.setAttribute('required', '')
|
||||
} else {
|
||||
target.removeAttribute('required')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
get fieldTargets() {
|
||||
return {
|
||||
ticker: this.tickerInputTarget,
|
||||
amount: this.amountInputTarget,
|
||||
transferAccount: this.transferAccountInputTarget,
|
||||
qty: this.qtyInputTarget,
|
||||
price: this.priceInputTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,12 @@ class ApplicationMailer < ActionMailer::Base
|
||||
|
||||
private
|
||||
|
||||
def set_self_host_settings
|
||||
mail.from = Setting.email_sender
|
||||
mail.delivery_method.settings.merge!({ address: Setting.smtp_host,
|
||||
port: Setting.smtp_port,
|
||||
user_name: Setting.smtp_username,
|
||||
password: Setting.smtp_password,
|
||||
tls: ENV.fetch("SMTP_TLS_ENABLED", "true") == "true" })
|
||||
end
|
||||
def set_self_host_settings
|
||||
mail.from = Setting.email_sender
|
||||
mail.delivery_method.settings.merge!({ address: Setting.smtp_host,
|
||||
port: Setting.smtp_port,
|
||||
user_name: Setting.smtp_username,
|
||||
password: Setting.smtp_password,
|
||||
tls: ENV.fetch("SMTP_TLS_ENABLED", "true") == "true" })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
class Account < ApplicationRecord
|
||||
include Syncable
|
||||
include Monetizable
|
||||
include Syncable, Monetizable, Issuable
|
||||
|
||||
validates :name, :balance, :currency, presence: true
|
||||
|
||||
@@ -15,6 +14,7 @@ class Account < ApplicationRecord
|
||||
has_many :balances, dependent: :destroy
|
||||
has_many :imports, dependent: :destroy
|
||||
has_many :syncs, dependent: :destroy
|
||||
has_many :issues, as: :issuable, dependent: :destroy
|
||||
|
||||
monetize :balance
|
||||
|
||||
@@ -28,8 +28,12 @@ class Account < ApplicationRecord
|
||||
|
||||
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
||||
|
||||
accepts_nested_attributes_for :accountable
|
||||
|
||||
delegate :value, :series, to: :accountable
|
||||
|
||||
class << self
|
||||
def by_group(period: Period.all, currency: Money.default_currency)
|
||||
def by_group(period: Period.all, currency: Money.default_currency.iso_code)
|
||||
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
|
||||
|
||||
Accountable.by_classification.each do |classification, types|
|
||||
@@ -49,51 +53,42 @@ class Account < ApplicationRecord
|
||||
end
|
||||
|
||||
def create_with_optional_start_balance!(attributes:, start_date: nil, start_balance: nil)
|
||||
account = self.new(attributes.except(:accountable_type))
|
||||
account.accountable = Accountable.from_type(attributes[:accountable_type])&.new
|
||||
transaction do
|
||||
attributes[:accountable_attributes] ||= {} # Ensure accountable is created
|
||||
account = new(attributes)
|
||||
|
||||
# Always build the initial valuation
|
||||
account.entries.build \
|
||||
date: Date.current,
|
||||
amount: attributes[:balance],
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
|
||||
# Conditionally build the optional start valuation
|
||||
if start_date.present? && start_balance.present?
|
||||
# Always initialize an account with a valuation entry to begin tracking value history
|
||||
account.entries.build \
|
||||
date: start_date,
|
||||
amount: start_balance,
|
||||
date: Date.current,
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
|
||||
account.save!
|
||||
account
|
||||
if start_date.present? && start_balance.present?
|
||||
account.entries.build \
|
||||
date: start_date,
|
||||
amount: start_balance,
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
|
||||
account.save!
|
||||
account
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def alert
|
||||
latest_sync = syncs.latest
|
||||
[ latest_sync&.error, *latest_sync&.warnings ].compact.first
|
||||
def owns_ticker?(ticker)
|
||||
security_id = Security.find_by(ticker: ticker)&.id
|
||||
entries.account_trades
|
||||
.joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id")
|
||||
.where(account_trades: { security_id: security_id }).any?
|
||||
end
|
||||
|
||||
def favorable_direction
|
||||
classification == "asset" ? "up" : "down"
|
||||
end
|
||||
|
||||
def series(period: Period.all, currency: self.currency)
|
||||
balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code)
|
||||
|
||||
if balance_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.from_collection(balance_series, :balance_money)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
|
||||
def update_balance!(balance)
|
||||
valuation = entries.account_valuations.find_by(date: Date.current)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
class Account::Balance < ApplicationRecord
|
||||
include Monetizable
|
||||
include Monetizable
|
||||
|
||||
belongs_to :account
|
||||
validates :account, :date, :balance, presence: true
|
||||
monetize :balance
|
||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||
scope :chronological, -> { order(:date) }
|
||||
belongs_to :account
|
||||
validates :account, :date, :balance, presence: true
|
||||
monetize :balance
|
||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||
scope :chronological, -> { order(:date) }
|
||||
end
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
class Account::Balance::Syncer
|
||||
attr_reader :warnings
|
||||
|
||||
def initialize(account, start_date: nil)
|
||||
@account = account
|
||||
@warnings = []
|
||||
@sync_start_date = calculate_sync_start_date(start_date)
|
||||
end
|
||||
|
||||
@@ -20,6 +17,8 @@ class Account::Balance::Syncer
|
||||
account.update! balance: daily_balances.select { |db| db.currency == account.currency }.last&.balance
|
||||
end
|
||||
end
|
||||
rescue Money::ConversionError => e
|
||||
account.observe_missing_exchange_rates(from: e.from_currency, to: e.to_currency, dates: [ e.date ])
|
||||
end
|
||||
|
||||
private
|
||||
@@ -67,20 +66,26 @@ class Account::Balance::Syncer
|
||||
from_currency = account.currency
|
||||
to_currency = account.family.currency
|
||||
|
||||
if ExchangeRate.exchange_rates_provider.nil?
|
||||
account.observe_missing_exchange_rate_provider
|
||||
return []
|
||||
end
|
||||
|
||||
exchange_rates = ExchangeRate.find_rates from: from_currency,
|
||||
to: to_currency,
|
||||
start_date: sync_start_date
|
||||
|
||||
missing_exchange_rates = balances.map(&:date) - exchange_rates.map(&:date)
|
||||
|
||||
if missing_exchange_rates.any?
|
||||
account.observe_missing_exchange_rates(from: from_currency, to: to_currency, dates: missing_exchange_rates)
|
||||
return []
|
||||
end
|
||||
|
||||
balances.map do |balance|
|
||||
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
|
||||
|
||||
raise Money::ConversionError.new("missing exchange rate from #{from_currency} to #{to_currency} on date #{balance.date}") unless exchange_rate
|
||||
|
||||
build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
@warnings << "missing exchange rates from #{from_currency} to #{to_currency}"
|
||||
[]
|
||||
end
|
||||
|
||||
def build_balance(date, balance, currency = nil)
|
||||
|
||||
@@ -204,7 +204,13 @@ class Account::Entry < ApplicationRecord
|
||||
current_qty = account.holding_qty(account_trade.security)
|
||||
|
||||
if current_qty < account_trade.qty.abs
|
||||
errors.add(:base, "cannot sell #{account_trade.qty.abs} shares of #{account_trade.security.symbol} because you only own #{current_qty} shares")
|
||||
errors.add(
|
||||
:base,
|
||||
:invalid_sell_quantity,
|
||||
sell_qty: account_trade.qty.abs,
|
||||
ticker: account_trade.security.ticker,
|
||||
current_qty: current_qty
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
45
app/models/account/entry_builder.rb
Normal file
45
app/models/account/entry_builder.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
class Account::EntryBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
TYPES = %w[income expense buy sell interest transfer_in transfer_out].freeze
|
||||
|
||||
attr_accessor :type, :date, :qty, :ticker, :price, :amount, :currency, :account, :transfer_account_id
|
||||
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
|
||||
def save
|
||||
if valid?
|
||||
create_builder.save
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_builder
|
||||
case type
|
||||
when "buy", "sell"
|
||||
create_trade_builder
|
||||
else
|
||||
create_transaction_builder
|
||||
end
|
||||
end
|
||||
|
||||
def create_trade_builder
|
||||
Account::TradeBuilder.new \
|
||||
type: type,
|
||||
date: date,
|
||||
qty: qty,
|
||||
ticker: ticker,
|
||||
price: price,
|
||||
account: account
|
||||
end
|
||||
|
||||
def create_transaction_builder
|
||||
Account::TransactionBuilder.new \
|
||||
type: type,
|
||||
date: date,
|
||||
amount: amount,
|
||||
account: account,
|
||||
transfer_account_id: transfer_account_id
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
module Account::Entryable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
TYPES = %w[ Account::Valuation Account::Transaction Account::Trade ]
|
||||
TYPES = %w[Account::Valuation Account::Transaction Account::Trade]
|
||||
|
||||
def self.from_type(entryable_type)
|
||||
entryable_type.presence_in(TYPES).constantize
|
||||
|
||||
@@ -10,15 +10,20 @@ class Account::Holding < ApplicationRecord
|
||||
|
||||
scope :chronological, -> { order(:date) }
|
||||
scope :current, -> { where(date: Date.current).order(amount: :desc) }
|
||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||
scope :known_value, -> { where.not(amount: nil) }
|
||||
scope :for, ->(security) { where(security_id: security).order(:date) }
|
||||
|
||||
delegate :name, to: :security
|
||||
delegate :symbol, to: :security
|
||||
delegate :ticker, to: :security
|
||||
|
||||
def name
|
||||
security.name || ticker
|
||||
end
|
||||
|
||||
def weight
|
||||
return nil unless amount
|
||||
|
||||
portfolio_value = account.holdings.current.where.not(amount: nil).sum(&:amount)
|
||||
portfolio_value = account.holdings.current.known_value.sum(&:amount)
|
||||
portfolio_value.zero? ? 1 : amount / portfolio_value * 100
|
||||
end
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
class Account::Holding::Syncer
|
||||
attr_reader :warnings
|
||||
|
||||
def initialize(account, start_date: nil)
|
||||
@account = account
|
||||
@warnings = []
|
||||
@sync_date_range = calculate_sync_start_date(start_date)..Date.current
|
||||
@portfolio = {}
|
||||
|
||||
@@ -32,16 +29,44 @@ class Account::Holding::Syncer
|
||||
.order(:date)
|
||||
end
|
||||
|
||||
def get_cached_price(ticker, date)
|
||||
return nil unless security_prices.key?(ticker)
|
||||
|
||||
price = security_prices[ticker].find { |p| p.date == date }
|
||||
price ? price[:price] : nil
|
||||
end
|
||||
|
||||
def security_prices
|
||||
@security_prices ||= begin
|
||||
prices = {}
|
||||
ticker_start_dates = {}
|
||||
|
||||
sync_entries.each do |entry|
|
||||
unless ticker_start_dates[entry.account_trade.security.ticker]
|
||||
ticker_start_dates[entry.account_trade.security.ticker] = entry.date
|
||||
end
|
||||
end
|
||||
|
||||
ticker_start_dates.each do |ticker, date|
|
||||
prices[ticker] = Security::Price.find_prices(ticker: ticker, start_date: date, end_date: Date.current)
|
||||
end
|
||||
|
||||
prices
|
||||
end
|
||||
end
|
||||
|
||||
def build_holdings_for_date(date)
|
||||
trades = sync_entries.select { |trade| trade.date == date }
|
||||
|
||||
@portfolio = generate_next_portfolio(@portfolio, trades)
|
||||
|
||||
@portfolio.map do |isin, holding|
|
||||
@portfolio.map do |ticker, holding|
|
||||
trade = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] }
|
||||
trade_price = trade&.account_trade&.price
|
||||
|
||||
price = Security::Price.find_by(date: date, isin: isin)&.price || trade_price
|
||||
price = get_cached_price(ticker, date) || trade_price
|
||||
|
||||
account.observe_missing_price(ticker:, date:) unless price
|
||||
|
||||
account.holdings.build \
|
||||
date: date,
|
||||
@@ -58,10 +83,10 @@ class Account::Holding::Syncer
|
||||
trade = entry.account_trade
|
||||
|
||||
price = trade.price
|
||||
prior_qty = prior_portfolio.dig(trade.security.isin, :qty) || 0
|
||||
prior_qty = prior_portfolio.dig(trade.security.ticker, :qty) || 0
|
||||
new_qty = prior_qty + trade.qty
|
||||
|
||||
new_portfolio[trade.security.isin] = {
|
||||
new_portfolio[trade.security.ticker] = {
|
||||
qty: new_qty,
|
||||
price: price,
|
||||
amount: new_qty * price,
|
||||
@@ -86,7 +111,7 @@ class Account::Holding::Syncer
|
||||
prior_day_holdings = account.holdings.where(date: sync_date_range.begin - 1.day)
|
||||
|
||||
prior_day_holdings.each do |holding|
|
||||
@portfolio[holding.security.isin] = {
|
||||
@portfolio[holding.security.ticker] = {
|
||||
qty: holding.qty,
|
||||
price: holding.price,
|
||||
amount: holding.amount,
|
||||
|
||||
@@ -16,34 +16,27 @@ class Account::Sync < ApplicationRecord
|
||||
def run
|
||||
start!
|
||||
|
||||
account.resolve_stale_issues
|
||||
|
||||
sync_balances
|
||||
sync_holdings
|
||||
|
||||
complete!
|
||||
rescue StandardError => error
|
||||
account.observe_unknown_issue(error)
|
||||
fail! error
|
||||
|
||||
raise error if Rails.env.development?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_balances
|
||||
syncer = Account::Balance::Syncer.new(account, start_date: start_date)
|
||||
|
||||
syncer.run
|
||||
|
||||
append_warnings(syncer.warnings)
|
||||
Account::Balance::Syncer.new(account, start_date: start_date).run
|
||||
end
|
||||
|
||||
def sync_holdings
|
||||
syncer = Account::Holding::Syncer.new(account, start_date: start_date)
|
||||
|
||||
syncer.run
|
||||
|
||||
append_warnings(syncer.warnings)
|
||||
end
|
||||
|
||||
def append_warnings(new_warnings)
|
||||
update! warnings: warnings + new_warnings
|
||||
Account::Holding::Syncer.new(account, start_date: start_date).run
|
||||
end
|
||||
|
||||
def start!
|
||||
@@ -53,12 +46,17 @@ class Account::Sync < ApplicationRecord
|
||||
|
||||
def complete!
|
||||
update! status: "completed"
|
||||
broadcast_result type: "notice", message: "Sync complete"
|
||||
|
||||
if account.has_issues?
|
||||
broadcast_result type: "alert", message: account.highest_priority_issue.title
|
||||
else
|
||||
broadcast_result type: "notice", message: "Sync complete"
|
||||
end
|
||||
end
|
||||
|
||||
def fail!(error)
|
||||
update! status: "failed", error: error.message
|
||||
broadcast_result type: "alert", message: error.message
|
||||
broadcast_result type: "alert", message: I18n.t("account.sync.failed")
|
||||
end
|
||||
|
||||
def broadcast_start
|
||||
@@ -78,6 +76,7 @@ class Account::Sync < ApplicationRecord
|
||||
partial: "shared/notification",
|
||||
locals: { type: type, message: message }
|
||||
)
|
||||
|
||||
account.family.broadcast_refresh
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
class Account::Trade < ApplicationRecord
|
||||
include Account::Entryable
|
||||
include Account::Entryable, Monetizable
|
||||
|
||||
monetize :price
|
||||
|
||||
belongs_to :security
|
||||
|
||||
validates :qty, presence: true, numericality: { other_than: 0 }
|
||||
validates :price, presence: true
|
||||
validates :price, :currency, presence: true
|
||||
|
||||
class << self
|
||||
def search(_params)
|
||||
|
||||
46
app/models/account/trade_builder.rb
Normal file
46
app/models/account/trade_builder.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
class Account::TradeBuilder < Account::EntryBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
TYPES = %w[buy sell].freeze
|
||||
|
||||
attr_accessor :type, :qty, :price, :ticker, :date, :account
|
||||
|
||||
validates :type, :qty, :price, :ticker, :date, presence: true
|
||||
validates :price, numericality: { greater_than: 0 }
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
|
||||
def save
|
||||
if valid?
|
||||
create_entry
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_entry
|
||||
account.entries.account_trades.create! \
|
||||
date: date,
|
||||
amount: amount,
|
||||
currency: account.currency,
|
||||
entryable: Account::Trade.new(
|
||||
security: security,
|
||||
qty: signed_qty,
|
||||
price: price.to_d,
|
||||
currency: account.currency
|
||||
)
|
||||
end
|
||||
|
||||
def security
|
||||
Security.find_or_create_by(ticker: ticker)
|
||||
end
|
||||
|
||||
def amount
|
||||
price.to_d * signed_qty
|
||||
end
|
||||
|
||||
def signed_qty
|
||||
_qty = qty.to_d
|
||||
_qty = _qty * -1 if type == "sell"
|
||||
_qty
|
||||
end
|
||||
end
|
||||
@@ -25,7 +25,7 @@ class Account::Transaction < ApplicationRecord
|
||||
private
|
||||
|
||||
def searchable_keys
|
||||
%i[ categories merchants ]
|
||||
%i[categories merchants]
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
63
app/models/account/transaction_builder.rb
Normal file
63
app/models/account/transaction_builder.rb
Normal file
@@ -0,0 +1,63 @@
|
||||
class Account::TransactionBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
TYPES = %w[income expense interest transfer_in transfer_out].freeze
|
||||
|
||||
attr_accessor :type, :amount, :date, :account, :transfer_account_id
|
||||
|
||||
validates :type, :amount, :date, presence: true
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
|
||||
def save
|
||||
if valid?
|
||||
transfer? ? create_transfer : create_transaction
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def transfer?
|
||||
%w[transfer_in transfer_out].include?(type)
|
||||
end
|
||||
|
||||
def create_transfer
|
||||
return create_unlinked_transfer(account.id, signed_amount) if transfer_account_id.blank?
|
||||
|
||||
from_account_id = type == "transfer_in" ? transfer_account_id : account.id
|
||||
to_account_id = type == "transfer_in" ? account.id : transfer_account_id
|
||||
|
||||
outflow = create_unlinked_transfer(from_account_id, signed_amount.abs)
|
||||
inflow = create_unlinked_transfer(to_account_id, signed_amount.abs * -1)
|
||||
|
||||
Account::Transfer.create! entries: [ outflow, inflow ]
|
||||
|
||||
inflow
|
||||
end
|
||||
|
||||
def create_unlinked_transfer(account_id, amount)
|
||||
build_entry(account_id, amount, marked_as_transfer: true).tap(&:save!)
|
||||
end
|
||||
|
||||
def create_transaction
|
||||
build_entry(account.id, signed_amount).tap(&:save!)
|
||||
end
|
||||
|
||||
def build_entry(account_id, amount, marked_as_transfer: false)
|
||||
Account::Entry.new \
|
||||
account_id: account_id,
|
||||
amount: amount,
|
||||
currency: account.currency,
|
||||
date: date,
|
||||
marked_as_transfer: marked_as_transfer,
|
||||
entryable: Account::Transaction.new
|
||||
end
|
||||
|
||||
def signed_amount
|
||||
case type
|
||||
when "expense", "transfer_out"
|
||||
amount.to_d
|
||||
else
|
||||
amount.to_d * -1
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,20 +9,19 @@ class Account::Transfer < ApplicationRecord
|
||||
end
|
||||
|
||||
def amount_money
|
||||
entries.first&.amount_money&.abs
|
||||
entries.first&.amount_money&.abs || Money.new(0)
|
||||
end
|
||||
|
||||
def from_name
|
||||
outflow_transaction&.account&.name
|
||||
outflow_transaction&.account&.name || I18n.t("account/transfer.from_fallback_name")
|
||||
end
|
||||
|
||||
def to_name
|
||||
inflow_transaction&.account&.name
|
||||
inflow_transaction&.account&.name || I18n.t("account/transfer.to_fallback_name")
|
||||
end
|
||||
|
||||
def name
|
||||
return nil unless from_name && to_name
|
||||
I18n.t("account.transfer.name", from_account: from_name, to_account: to_name)
|
||||
I18n.t("account/transfer.name", from_account: from_name, to_account: to_name)
|
||||
end
|
||||
|
||||
def inflow_transaction
|
||||
@@ -47,7 +46,7 @@ class Account::Transfer < ApplicationRecord
|
||||
def build_from_accounts(from_account, to_account, date:, amount:, currency:, name:)
|
||||
outflow = from_account.entries.build \
|
||||
amount: amount.abs,
|
||||
currency: currency,
|
||||
currency: from_account.currency,
|
||||
date: date,
|
||||
name: name,
|
||||
marked_as_transfer: true,
|
||||
@@ -55,7 +54,7 @@ class Account::Transfer < ApplicationRecord
|
||||
|
||||
inflow = to_account.entries.build \
|
||||
amount: amount.abs * -1,
|
||||
currency: currency,
|
||||
currency: from_account.currency,
|
||||
date: date,
|
||||
name: name,
|
||||
marked_as_transfer: true,
|
||||
@@ -73,24 +72,24 @@ class Account::Transfer < ApplicationRecord
|
||||
|
||||
def transaction_count
|
||||
unless entries.size == 2
|
||||
errors.add :entries, "must have exactly 2 entries"
|
||||
errors.add :entries, :must_have_exactly_2_entries
|
||||
end
|
||||
end
|
||||
|
||||
def from_different_accounts
|
||||
accounts = entries.map { |e| e.account_id }.uniq
|
||||
errors.add :entries, "must be from different accounts" if accounts.size < entries.size
|
||||
errors.add :entries, :must_be_from_different_accounts if accounts.size < entries.size
|
||||
end
|
||||
|
||||
def net_zero_flows
|
||||
unless entries.sum(&:amount).zero?
|
||||
errors.add :transactions, "must have an inflow and outflow that net to zero"
|
||||
errors.add :entries, :must_have_an_inflow_and_outflow_that_net_to_zero
|
||||
end
|
||||
end
|
||||
|
||||
def all_transactions_marked
|
||||
unless entries.all?(&:marked_as_transfer)
|
||||
errors.add :entries, "must be marked as transfer"
|
||||
errors.add :entries, :must_be_marked_as_transfer
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
24
app/models/address.rb
Normal file
24
app/models/address.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
class Address < ApplicationRecord
|
||||
belongs_to :addressable, polymorphic: true
|
||||
|
||||
validates :line1, :locality, presence: true
|
||||
validates :postal_code, presence: true, if: :postal_code_required?
|
||||
|
||||
def to_s
|
||||
I18n.t("address.format",
|
||||
line1: line1,
|
||||
line2: line2,
|
||||
county: county,
|
||||
locality: locality,
|
||||
region: region,
|
||||
country: country,
|
||||
postal_code: postal_code
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def postal_code_required?
|
||||
country.in?(%w[US CA GB])
|
||||
end
|
||||
end
|
||||
@@ -47,7 +47,7 @@ class Category < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def clear_internal_category
|
||||
self.internal_category = nil
|
||||
end
|
||||
def clear_internal_category
|
||||
self.internal_category = nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module Accountable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
ASSET_TYPES = %w[ Depository Investment Crypto Property Vehicle OtherAsset ]
|
||||
LIABILITY_TYPES = %w[ CreditCard Loan OtherLiability ]
|
||||
ASSET_TYPES = %w[Depository Investment Crypto Property Vehicle OtherAsset]
|
||||
LIABILITY_TYPES = %w[CreditCard Loan OtherLiability]
|
||||
TYPES = ASSET_TYPES + LIABILITY_TYPES
|
||||
|
||||
def self.from_type(type)
|
||||
@@ -17,4 +17,20 @@ module Accountable
|
||||
included do
|
||||
has_one :account, as: :accountable, touch: true
|
||||
end
|
||||
|
||||
def value
|
||||
account.balance_money
|
||||
end
|
||||
|
||||
def series(period: Period.all, currency: account.currency)
|
||||
balance_series = account.balances.in_period(period).where(currency: currency)
|
||||
|
||||
if balance_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: account.balance_money.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.from_collection(balance_series, :balance_money)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
end
|
||||
|
||||
58
app/models/concerns/issuable.rb
Normal file
58
app/models/concerns/issuable.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
module Issuable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :issues, dependent: :destroy, as: :issuable
|
||||
end
|
||||
|
||||
def has_issues?
|
||||
issues.active.any?
|
||||
end
|
||||
|
||||
def resolve_stale_issues
|
||||
issues.active.each do |issue|
|
||||
issue.resolve! if issue.stale?
|
||||
end
|
||||
end
|
||||
|
||||
def observe_unknown_issue(error)
|
||||
observe_issue(
|
||||
Issue::Unknown.new(data: { error: error.message })
|
||||
)
|
||||
end
|
||||
|
||||
def observe_missing_exchange_rates(from:, to:, dates:)
|
||||
observe_issue(
|
||||
Issue::ExchangeRatesMissing.new(data: { from_currency: from, to_currency: to, dates: dates })
|
||||
)
|
||||
end
|
||||
|
||||
def observe_missing_exchange_rate_provider
|
||||
observe_issue(
|
||||
Issue::ExchangeRateProviderMissing.new
|
||||
)
|
||||
end
|
||||
|
||||
def observe_missing_price(ticker:, date:)
|
||||
issue = issues.find_or_create_by(type: Issue::PricesMissing.name, resolved_at: nil)
|
||||
issue.append_missing_price(ticker, date)
|
||||
issue.save!
|
||||
end
|
||||
|
||||
def highest_priority_issue
|
||||
issues.active.ordered.first
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def observe_issue(new_issue)
|
||||
existing_issue = issues.find_by(type: new_issue.type, resolved_at: nil)
|
||||
|
||||
if existing_issue
|
||||
existing_issue.update!(last_observed_at: Time.current, data: new_issue.data)
|
||||
else
|
||||
new_issue.issuable = self
|
||||
new_issue.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,14 +1,14 @@
|
||||
module Monetizable
|
||||
extend ActiveSupport::Concern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def monetize(*fields)
|
||||
fields.each do |field|
|
||||
define_method("#{field}_money") do
|
||||
value = self.send(field)
|
||||
value.nil? ? nil : Money.new(value, currency || Money.default_currency)
|
||||
end
|
||||
class_methods do
|
||||
def monetize(*fields)
|
||||
fields.each do |field|
|
||||
define_method("#{field}_money") do
|
||||
value = self.send(field)
|
||||
value.nil? ? nil : Money.new(value, currency || Money.default_currency)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,18 +6,27 @@ module Providable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def exchange_rates_provider
|
||||
api_key = ENV["SYNTH_API_KEY"]
|
||||
def security_prices_provider
|
||||
synth_provider
|
||||
end
|
||||
|
||||
if api_key.present?
|
||||
Provider::Synth.new api_key
|
||||
else
|
||||
nil
|
||||
end
|
||||
def exchange_rates_provider
|
||||
synth_provider
|
||||
end
|
||||
|
||||
def git_repository_provider
|
||||
Provider::Github.new
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def synth_provider
|
||||
api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"]
|
||||
api_key.present? ? Provider::Synth.new(api_key) : nil
|
||||
end
|
||||
|
||||
def self_hosted?
|
||||
Rails.application.config.app_mode.self_hosted?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ class Demo::Generator
|
||||
end
|
||||
|
||||
def reset_and_clear_data!
|
||||
reset_settings!
|
||||
clear_data!
|
||||
create_user!
|
||||
|
||||
@@ -14,6 +15,7 @@ class Demo::Generator
|
||||
|
||||
def reset_data!
|
||||
Family.transaction do
|
||||
reset_settings!
|
||||
clear_data!
|
||||
create_user!
|
||||
|
||||
@@ -52,11 +54,17 @@ class Demo::Generator
|
||||
end
|
||||
|
||||
def clear_data!
|
||||
InviteCode.destroy_all
|
||||
User.find_by_email("user@maybe.local")&.destroy
|
||||
ExchangeRate.destroy_all
|
||||
Security.destroy_all
|
||||
Security::Price.destroy_all
|
||||
end
|
||||
|
||||
def reset_settings!
|
||||
Setting.destroy_all
|
||||
end
|
||||
|
||||
def create_user!
|
||||
family.users.create! \
|
||||
email: "user@maybe.local",
|
||||
@@ -166,12 +174,12 @@ class Demo::Generator
|
||||
|
||||
def load_securities!
|
||||
# Create an unknown security to simulate edge cases
|
||||
Security.create! isin: "unknown", symbol: "UNKNOWN", name: "Unknown Demo Stock"
|
||||
Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock"
|
||||
|
||||
securities = [
|
||||
{ isin: "US0378331005", symbol: "AAPL", name: "Apple Inc.", reference_price: 210 },
|
||||
{ isin: "JP3633400001", symbol: "TM", name: "Toyota Motor Corporation", reference_price: 202 },
|
||||
{ isin: "US5949181045", symbol: "MSFT", name: "Microsoft Corporation", reference_price: 455 }
|
||||
{ ticker: "AAPL", name: "Apple Inc.", reference_price: 210 },
|
||||
{ ticker: "TM", name: "Toyota Motor Corporation", reference_price: 202 },
|
||||
{ ticker: "MSFT", name: "Microsoft Corporation", reference_price: 455 }
|
||||
]
|
||||
|
||||
securities.each do |security_attributes|
|
||||
@@ -183,7 +191,7 @@ class Demo::Generator
|
||||
low_price = reference - 20
|
||||
high_price = reference + 20
|
||||
Security::Price.create! \
|
||||
isin: security.isin,
|
||||
ticker: security.ticker,
|
||||
date: date,
|
||||
price: Faker::Number.positive(from: low_price, to: high_price)
|
||||
end
|
||||
@@ -200,13 +208,13 @@ class Demo::Generator
|
||||
currency: "USD",
|
||||
institution: family.institutions.find_or_create_by(name: "Robinhood")
|
||||
|
||||
aapl = Security.find_by(symbol: "AAPL")
|
||||
tm = Security.find_by(symbol: "TM")
|
||||
msft = Security.find_by(symbol: "MSFT")
|
||||
unknown = Security.find_by(symbol: "UNKNOWN")
|
||||
aapl = Security.find_by(ticker: "AAPL")
|
||||
tm = Security.find_by(ticker: "TM")
|
||||
msft = Security.find_by(ticker: "MSFT")
|
||||
unknown = Security.find_by(ticker: "UNKNOWN")
|
||||
|
||||
# Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices
|
||||
account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown)
|
||||
account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown, currency: "USD")
|
||||
|
||||
trades = [
|
||||
{ security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 },
|
||||
@@ -219,15 +227,15 @@ class Demo::Generator
|
||||
date = Faker::Number.positive(to: 730).days.ago.to_date
|
||||
security = trade[:security]
|
||||
qty = trade[:qty]
|
||||
price = Security::Price.find_by(isin: security.isin, date: date)&.price || 1
|
||||
price = Security::Price.find_by(ticker: security.ticker, date: date)&.price || 1
|
||||
name_prefix = qty < 0 ? "Sell " : "Buy "
|
||||
|
||||
account.entries.create! \
|
||||
date: date,
|
||||
amount: qty * price,
|
||||
currency: "USD",
|
||||
name: name_prefix + "#{qty} shares of #{security.symbol}",
|
||||
entryable: Account::Trade.new(qty: qty, price: price, security: security)
|
||||
name: name_prefix + "#{qty} shares of #{security.ticker}",
|
||||
entryable: Account::Trade.new(qty: qty, price: price, currency: "USD", security: security)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -15,12 +15,11 @@ class ExchangeRate < ApplicationRecord
|
||||
|
||||
def find_rates(from:, to:, start_date:, end_date: Date.current, cache: true)
|
||||
rates = self.where(from_currency: from, to_currency: to, date: start_date..end_date).to_a
|
||||
all_dates = (start_date..end_date).to_a.to_set
|
||||
existing_dates = rates.map(&:date).to_set
|
||||
all_dates = (start_date..end_date).to_a
|
||||
existing_dates = rates.map(&:date)
|
||||
missing_dates = all_dates - existing_dates
|
||||
|
||||
if missing_dates.any?
|
||||
rates += fetch_rates_from_provider(from:, to:, dates: missing_dates, cache:)
|
||||
rates += fetch_rates_from_provider(from:, to:, start_date: missing_dates.first, end_date: missing_dates.last, cache:)
|
||||
end
|
||||
|
||||
rates
|
||||
|
||||
@@ -4,14 +4,37 @@ module ExchangeRate::Provided
|
||||
include Providable
|
||||
|
||||
class_methods do
|
||||
def provider_healthy?
|
||||
exchange_rates_provider.present? && exchange_rates_provider.healthy?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_rates_from_provider(from:, to:, dates:, cache: false)
|
||||
def fetch_rates_from_provider(from:, to:, start_date:, end_date: Date.current, cache: false)
|
||||
return [] unless exchange_rates_provider.present?
|
||||
|
||||
dates.map do |date|
|
||||
fetch_rate_from_provider from:, to:, date:, cache:
|
||||
end.compact
|
||||
response = exchange_rates_provider.fetch_exchange_rates \
|
||||
from: from,
|
||||
to: to,
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
|
||||
if response.success?
|
||||
response.rates.map do |exchange_rate|
|
||||
rate = ExchangeRate.new \
|
||||
from_currency: from,
|
||||
to_currency: to,
|
||||
date: exchange_rate.dig(:date).to_date,
|
||||
rate: exchange_rate.dig(:rate)
|
||||
|
||||
rate.save! if cache
|
||||
rate
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
next
|
||||
end
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_rate_from_provider(from:, to:, date:, cache: false)
|
||||
|
||||
@@ -8,6 +8,7 @@ class Family < ApplicationRecord
|
||||
has_many :imports, through: :accounts
|
||||
has_many :categories, dependent: :destroy
|
||||
has_many :merchants, dependent: :destroy
|
||||
has_many :issues, through: :accounts
|
||||
|
||||
def snapshot(period = Period.all)
|
||||
query = accounts.active.joins(:balances)
|
||||
@@ -45,7 +46,8 @@ class Family < ApplicationRecord
|
||||
.where("account_entries.date <= ?", period.date_range.end)
|
||||
.where("account_entries.marked_as_transfer = ?", false)
|
||||
.where("account_entries.entryable_type = ?", "Account::Transaction")
|
||||
.group("id")
|
||||
.group("accounts.id")
|
||||
.having("SUM(ABS(account_entries.amount)) > 0")
|
||||
.to_a
|
||||
|
||||
results.each do |r|
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
class Help::Article
|
||||
attr_reader :frontmatter, :content
|
||||
|
||||
def initialize(frontmatter:, content:)
|
||||
@frontmatter = frontmatter
|
||||
@content = content
|
||||
end
|
||||
|
||||
def title
|
||||
frontmatter["title"]
|
||||
end
|
||||
|
||||
def html
|
||||
render_markdown(content)
|
||||
end
|
||||
|
||||
class << self
|
||||
def root_path
|
||||
Rails.root.join("docs", "help")
|
||||
end
|
||||
|
||||
def find(slug)
|
||||
Dir.glob(File.join(root_path, "*.md")).each do |file_path|
|
||||
file_content = File.read(file_path)
|
||||
frontmatter, markdown_content = parse_frontmatter(file_content)
|
||||
|
||||
return new(frontmatter:, content: markdown_content) if frontmatter["slug"] == slug
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_frontmatter(content)
|
||||
if content =~ /\A---(.+?)---/m
|
||||
frontmatter = YAML.safe_load($1)
|
||||
markdown_content = content[($~.end(0))..-1].strip
|
||||
else
|
||||
frontmatter = {}
|
||||
markdown_content = content
|
||||
end
|
||||
|
||||
[ frontmatter, markdown_content ]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_markdown(content)
|
||||
markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML)
|
||||
markdown.render(content)
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,8 @@
|
||||
class Import < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
validate :raw_csv_must_be_parsable
|
||||
validate :raw_file_must_be_parsable
|
||||
validates :col_sep, inclusion: { in: Csv::COL_SEP_LIST }
|
||||
|
||||
before_save :initialize_csv, if: :should_initialize_csv?
|
||||
|
||||
@@ -18,7 +19,7 @@ class Import < ApplicationRecord
|
||||
end
|
||||
|
||||
def loaded?
|
||||
raw_csv_str.present?
|
||||
raw_file_str.present?
|
||||
end
|
||||
|
||||
def configured?
|
||||
@@ -87,22 +88,22 @@ class Import < ApplicationRecord
|
||||
end
|
||||
|
||||
def get_raw_csv
|
||||
return nil if raw_csv_str.nil?
|
||||
Import::Csv.new(raw_csv_str)
|
||||
return nil if raw_file_str.nil?
|
||||
Import::Csv.new(raw_file_str, col_sep:)
|
||||
end
|
||||
|
||||
def should_initialize_csv?
|
||||
raw_csv_str_changed? || column_mappings_changed?
|
||||
raw_file_str_changed? || column_mappings_changed?
|
||||
end
|
||||
|
||||
def initialize_csv
|
||||
generated_csv = generate_normalized_csv(raw_csv_str)
|
||||
generated_csv = generate_normalized_csv(raw_file_str)
|
||||
self.normalized_csv_str = generated_csv.table.to_s
|
||||
end
|
||||
|
||||
# Uses the user-provided raw CSV + mappings to generate a normalized CSV for the import
|
||||
def generate_normalized_csv(csv_str)
|
||||
Import::Csv.create_with_field_mappings(csv_str, expected_fields, column_mappings)
|
||||
Import::Csv.create_with_field_mappings(csv_str, expected_fields, column_mappings, col_sep)
|
||||
end
|
||||
|
||||
def update_csv(row_idx, col_idx, value)
|
||||
@@ -174,11 +175,11 @@ class Import < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def raw_csv_must_be_parsable
|
||||
def raw_file_must_be_parsable
|
||||
begin
|
||||
CSV.parse(raw_csv_str || "")
|
||||
CSV.parse(raw_file_str || "", col_sep:)
|
||||
rescue CSV::MalformedCSVError
|
||||
errors.add(:raw_csv_str, "is not a valid CSV format")
|
||||
errors.add(:raw_file_str, :invalid_csv_format)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
class Import::Csv
|
||||
def self.parse_csv(csv_str)
|
||||
CSV.parse((csv_str || "").strip, headers: true, converters: [ ->(str) { str&.strip } ])
|
||||
DEFAULT_COL_SEP = ",".freeze
|
||||
COL_SEP_LIST = [ DEFAULT_COL_SEP, ";" ].freeze
|
||||
|
||||
def self.parse_csv(csv_str, col_sep: DEFAULT_COL_SEP)
|
||||
CSV.parse(
|
||||
csv_str&.strip || "",
|
||||
headers: true,
|
||||
col_sep:,
|
||||
converters: [ ->(str) { str&.strip } ]
|
||||
)
|
||||
end
|
||||
|
||||
def self.create_with_field_mappings(raw_csv_str, fields, field_mappings)
|
||||
raw_csv = self.parse_csv(raw_csv_str)
|
||||
def self.create_with_field_mappings(raw_file_str, fields, field_mappings, col_sep = DEFAULT_COL_SEP)
|
||||
raw_csv = self.parse_csv(raw_file_str, col_sep:)
|
||||
|
||||
generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true do |csv|
|
||||
generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true, col_sep: do |csv|
|
||||
raw_csv.each do |row|
|
||||
row_values = []
|
||||
|
||||
@@ -22,18 +30,19 @@ class Import::Csv
|
||||
end
|
||||
end
|
||||
|
||||
new(generated_csv_str)
|
||||
new(generated_csv_str, col_sep:)
|
||||
end
|
||||
|
||||
attr_reader :csv_str
|
||||
attr_reader :csv_str, :col_sep
|
||||
|
||||
def initialize(csv_str, column_validators: nil)
|
||||
def initialize(csv_str, column_validators: nil, col_sep: DEFAULT_COL_SEP)
|
||||
@csv_str = csv_str
|
||||
@col_sep = col_sep
|
||||
@column_validators = column_validators || {}
|
||||
end
|
||||
|
||||
def table
|
||||
@table ||= self.class.parse_csv(csv_str)
|
||||
@table ||= self.class.parse_csv(csv_str, col_sep:)
|
||||
end
|
||||
|
||||
def update_cell(row_idx, col_idx, value)
|
||||
|
||||
@@ -4,4 +4,22 @@ class Institution < ApplicationRecord
|
||||
has_one_attached :logo
|
||||
|
||||
scope :alphabetically, -> { order(name: :asc) }
|
||||
|
||||
def sync
|
||||
accounts.active.each do |account|
|
||||
if account.needs_sync?
|
||||
account.sync
|
||||
end
|
||||
end
|
||||
|
||||
update! last_synced_at: Time.now
|
||||
end
|
||||
|
||||
def syncing?
|
||||
accounts.active.any? { |account| account.syncing? }
|
||||
end
|
||||
|
||||
def has_issues?
|
||||
accounts.active.any? { |account| account.has_issues? }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,4 +13,35 @@ class Investment < ApplicationRecord
|
||||
[ "Roth 401k", "roth_401k" ],
|
||||
[ "Angel", "angel" ]
|
||||
].freeze
|
||||
|
||||
def value
|
||||
account.balance_money + holdings_value
|
||||
end
|
||||
|
||||
def holdings_value
|
||||
account.holdings.current.known_value.sum(&:amount) || Money.new(0, account.currency)
|
||||
end
|
||||
|
||||
def series(period: Period.all, currency: account.currency)
|
||||
balance_series = account.balances.in_period(period).where(currency: currency)
|
||||
holding_series = account.holdings.known_value.in_period(period).where(currency: currency)
|
||||
|
||||
holdings_by_date = holding_series.group_by(&:date).transform_values do |holdings|
|
||||
holdings.sum(&:amount)
|
||||
end
|
||||
|
||||
combined_series = balance_series.map do |balance|
|
||||
holding_amount = holdings_by_date[balance.date] || 0
|
||||
|
||||
{ date: balance.date, value: Money.new(balance.balance + holding_amount, currency) }
|
||||
end
|
||||
|
||||
if combined_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: self.value.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.new(combined_series)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,10 +16,10 @@ class InviteCode < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def generate_token
|
||||
loop do
|
||||
self.token = SecureRandom.hex(4)
|
||||
break token unless self.class.exists?(token: token)
|
||||
def generate_token
|
||||
loop do
|
||||
self.token = SecureRandom.hex(4)
|
||||
break token unless self.class.exists?(token: token)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
35
app/models/issue.rb
Normal file
35
app/models/issue.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class Issue < ApplicationRecord
|
||||
belongs_to :issuable, polymorphic: true
|
||||
|
||||
after_initialize :set_default_severity
|
||||
|
||||
enum :severity, { critical: 1, error: 2, warning: 3, info: 4 }
|
||||
|
||||
validates :severity, presence: true
|
||||
|
||||
scope :active, -> { where(resolved_at: nil) }
|
||||
scope :ordered, -> { order(:severity) }
|
||||
|
||||
def title
|
||||
model_name.human
|
||||
end
|
||||
|
||||
# The conditions that must be met for an issue to be fixed
|
||||
def stale?
|
||||
raise NotImplementedError, "#{self.class} must implement #{__method__}"
|
||||
end
|
||||
|
||||
def resolve!
|
||||
update!(resolved_at: Time.current)
|
||||
end
|
||||
|
||||
def default_severity
|
||||
:warning
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_default_severity
|
||||
self.severity ||= default_severity
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user