Compare commits
59 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cea90252c8 | ||
|
|
36cccefb2a | ||
|
|
cc6bf6e961 | ||
|
|
48092cb704 | ||
|
|
cf23453d93 | ||
|
|
f1d0a62ac7 | ||
|
|
3089e3c81d | ||
|
|
0593d8fb7e | ||
|
|
a8ea207d47 | ||
|
|
c225eb6d03 | ||
|
|
9b148316bc | ||
|
|
8e7fcfd0b4 | ||
|
|
c3314e62d1 | ||
|
|
320954282a | ||
|
|
9e1d8a753b | ||
|
|
3d4def59d6 | ||
|
|
da18c3d850 | ||
|
|
cb3fd34f90 | ||
|
|
593892bc2b | ||
|
|
bbcd3881db | ||
|
|
ee53546c1b | ||
|
|
66c27b8df4 | ||
|
|
03e027e089 | ||
|
|
b7799aaa8e | ||
|
|
094128fef1 | ||
|
|
a5212f0f5e | ||
|
|
62d5df795b | ||
|
|
3cae528dfd | ||
|
|
12380dc8ad | ||
|
|
0bc0d87768 | ||
|
|
e13c3d9271 | ||
|
|
1e0635b31a | ||
|
|
bddaab0192 | ||
|
|
dc3147c101 | ||
|
|
2681dd96b1 | ||
|
|
a947db92b2 | ||
|
|
778098ebb0 | ||
|
|
ca39b26070 | ||
|
|
b462bc8f8c | ||
|
|
73ecf0b912 | ||
|
|
cdaed495b3 | ||
|
|
651028a9f3 | ||
|
|
c4fb9a54a2 | ||
|
|
9af355fc59 | ||
|
|
773cd0da71 | ||
|
|
5da34c4609 | ||
|
|
957584b69c | ||
|
|
d0a15b8a98 | ||
|
|
9956a9540e | ||
|
|
8c1a7af37f | ||
|
|
c5704ffd45 | ||
|
|
8372e26864 | ||
|
|
6477c0f766 | ||
|
|
2a8bb57c9c | ||
|
|
2f432ec0c3 | ||
|
|
e3269e8981 | ||
|
|
8f891b8d8c | ||
|
|
775921092c | ||
|
|
83e2bfceb8 |
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -7,11 +7,6 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Where did this bug occur?**
|
||||
|
||||
- [ ] Local development
|
||||
- [ ] Self hosted app (i.e. Docker)
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
|
||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
|
||||
194
Gemfile.lock
194
Gemfile.lock
@@ -1,38 +1,38 @@
|
||||
GIT
|
||||
remote: https://github.com/maybe-finance/lucide-rails.git
|
||||
revision: 6170b3a0eceb43a8af6552638e9526673c356d0d
|
||||
revision: 79d989593ee4ac6c50106ec5e4d2bd4ec8f5af87
|
||||
specs:
|
||||
lucide-rails (0.2.0)
|
||||
railties (>= 4.1.0)
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: 8e7eb03d990e0a2a80fe1ea80133ef5d58bbc268
|
||||
revision: df028327844b6564be8d09075b52288260d4da42
|
||||
branch: 7-2-stable
|
||||
specs:
|
||||
actioncable (7.2.0.beta1)
|
||||
actionpack (= 7.2.0.beta1)
|
||||
activesupport (= 7.2.0.beta1)
|
||||
actioncable (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.2.0.beta1)
|
||||
actionpack (= 7.2.0.beta1)
|
||||
activejob (= 7.2.0.beta1)
|
||||
activerecord (= 7.2.0.beta1)
|
||||
activestorage (= 7.2.0.beta1)
|
||||
activesupport (= 7.2.0.beta1)
|
||||
actionmailbox (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activestorage (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (7.2.0.beta1)
|
||||
actionpack (= 7.2.0.beta1)
|
||||
actionview (= 7.2.0.beta1)
|
||||
activejob (= 7.2.0.beta1)
|
||||
activesupport (= 7.2.0.beta1)
|
||||
actionmailer (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
actionview (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.2.0.beta1)
|
||||
actionview (= 7.2.0.beta1)
|
||||
activesupport (= 7.2.0.beta1)
|
||||
actionpack (7.2.0.beta2)
|
||||
actionview (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4)
|
||||
@@ -41,60 +41,61 @@ GIT
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (7.2.0.beta1)
|
||||
actionpack (= 7.2.0.beta1)
|
||||
activerecord (= 7.2.0.beta1)
|
||||
activestorage (= 7.2.0.beta1)
|
||||
activesupport (= 7.2.0.beta1)
|
||||
actiontext (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activestorage (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.2.0.beta1)
|
||||
activesupport (= 7.2.0.beta1)
|
||||
actionview (7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.2.0.beta1)
|
||||
activesupport (= 7.2.0.beta1)
|
||||
activejob (7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.2.0.beta1)
|
||||
activesupport (= 7.2.0.beta1)
|
||||
activerecord (7.2.0.beta1)
|
||||
activemodel (= 7.2.0.beta1)
|
||||
activesupport (= 7.2.0.beta1)
|
||||
activemodel (7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
activerecord (7.2.0.beta2)
|
||||
activemodel (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.2.0.beta1)
|
||||
actionpack (= 7.2.0.beta1)
|
||||
activejob (= 7.2.0.beta1)
|
||||
activerecord (= 7.2.0.beta1)
|
||||
activesupport (= 7.2.0.beta1)
|
||||
activestorage (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.2.0.beta1)
|
||||
activesupport (7.2.0.beta2)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
rails (7.2.0.beta1)
|
||||
actioncable (= 7.2.0.beta1)
|
||||
actionmailbox (= 7.2.0.beta1)
|
||||
actionmailer (= 7.2.0.beta1)
|
||||
actionpack (= 7.2.0.beta1)
|
||||
actiontext (= 7.2.0.beta1)
|
||||
actionview (= 7.2.0.beta1)
|
||||
activejob (= 7.2.0.beta1)
|
||||
activemodel (= 7.2.0.beta1)
|
||||
activerecord (= 7.2.0.beta1)
|
||||
activestorage (= 7.2.0.beta1)
|
||||
activesupport (= 7.2.0.beta1)
|
||||
rails (7.2.0.beta2)
|
||||
actioncable (= 7.2.0.beta2)
|
||||
actionmailbox (= 7.2.0.beta2)
|
||||
actionmailer (= 7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
actiontext (= 7.2.0.beta2)
|
||||
actionview (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activemodel (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activestorage (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.0.beta1)
|
||||
railties (7.2.0.beta1)
|
||||
actionpack (= 7.2.0.beta1)
|
||||
activesupport (= 7.2.0.beta1)
|
||||
railties (= 7.2.0.beta2)
|
||||
railties (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -108,17 +109,17 @@ GEM
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.930.0)
|
||||
aws-sdk-core (3.196.1)
|
||||
aws-partitions (1.949.0)
|
||||
aws-sdk-core (3.200.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.81.0)
|
||||
aws-sdk-core (~> 3, >= 3.193.0)
|
||||
aws-sdk-kms (1.87.0)
|
||||
aws-sdk-core (~> 3, >= 3.199.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.151.0)
|
||||
aws-sdk-core (~> 3, >= 3.194.0)
|
||||
aws-sdk-s3 (1.155.0)
|
||||
aws-sdk-core (~> 3, >= 3.199.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (1.8.0)
|
||||
@@ -138,7 +139,7 @@ GEM
|
||||
msgpack (~> 1.2)
|
||||
brakeman (6.1.2)
|
||||
racc
|
||||
builder (3.2.4)
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
addressable
|
||||
matrix
|
||||
@@ -150,7 +151,7 @@ GEM
|
||||
xpath (~> 3.2)
|
||||
childprocess (5.0.0)
|
||||
climate_control (1.2.0)
|
||||
concurrent-ruby (1.3.1)
|
||||
concurrent-ruby (1.3.3)
|
||||
connection_pool (2.4.1)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
@@ -174,10 +175,10 @@ GEM
|
||||
rainbow
|
||||
rubocop
|
||||
smart_properties
|
||||
erubi (1.12.0)
|
||||
erubi (1.13.0)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
faraday (2.9.0)
|
||||
faraday (2.9.2)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
faraday-net_http (3.1.0)
|
||||
net-http
|
||||
@@ -189,7 +190,7 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (3.29.2)
|
||||
good_job (3.29.5)
|
||||
activejob (>= 6.0.0)
|
||||
activerecord (>= 6.0.0)
|
||||
concurrent-ruby (>= 1.0.2)
|
||||
@@ -225,7 +226,7 @@ GEM
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.7.2)
|
||||
irb (1.13.1)
|
||||
irb (1.13.2)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
@@ -239,6 +240,7 @@ GEM
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.6.0)
|
||||
loofah (2.22.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
@@ -251,13 +253,13 @@ GEM
|
||||
matrix (0.4.2)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.23.1)
|
||||
mocha (2.3.0)
|
||||
minitest (5.24.1)
|
||||
mocha (2.4.0)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
net-http (0.4.1)
|
||||
uri
|
||||
net-imap (0.4.12)
|
||||
net-imap (0.4.14)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -267,23 +269,22 @@ GEM
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.5-aarch64-linux)
|
||||
nokogiri (1.16.6-aarch64-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-arm-linux)
|
||||
nokogiri (1.16.6-arm-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-arm64-darwin)
|
||||
nokogiri (1.16.6-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-x86-linux)
|
||||
nokogiri (1.16.6-x86-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-x86_64-darwin)
|
||||
nokogiri (1.16.6-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-x86_64-linux)
|
||||
nokogiri (1.16.6-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
octokit (8.1.0)
|
||||
base64
|
||||
octokit (9.1.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (8.4.1)
|
||||
pagy (8.6.1)
|
||||
parallel (1.24.0)
|
||||
parser (3.3.1.0)
|
||||
ast (~> 2.4.1)
|
||||
@@ -297,12 +298,12 @@ GEM
|
||||
railties (>= 7.0.0)
|
||||
psych (5.1.2)
|
||||
stringio
|
||||
public_suffix (5.0.5)
|
||||
public_suffix (5.1.0)
|
||||
puma (6.4.2)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.0)
|
||||
rack (3.0.11)
|
||||
rack (3.1.4)
|
||||
rack-session (2.0.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
@@ -331,10 +332,10 @@ GEM
|
||||
rdoc (6.7.0)
|
||||
psych (>= 4.0.0)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.8)
|
||||
reline (0.5.9)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.2.8)
|
||||
strscan (>= 3.0.9)
|
||||
rexml (3.3.0)
|
||||
strscan
|
||||
rubocop (1.63.5)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
@@ -378,15 +379,16 @@ GEM
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
selenium-webdriver (4.21.1)
|
||||
selenium-webdriver (4.22.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.17.3)
|
||||
sentry-rails (5.18.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.17.3)
|
||||
sentry-ruby (5.17.3)
|
||||
sentry-ruby (~> 5.18.0)
|
||||
sentry-ruby (5.18.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
simplecov (0.22.0)
|
||||
@@ -400,19 +402,19 @@ GEM
|
||||
stackprof (0.2.26)
|
||||
stimulus-rails (1.3.3)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.0)
|
||||
stringio (3.1.1)
|
||||
strscan (3.1.0)
|
||||
tailwindcss-rails (2.6.0)
|
||||
tailwindcss-rails (2.6.1)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.0-aarch64-linux)
|
||||
tailwindcss-rails (2.6.1-aarch64-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.0-arm-linux)
|
||||
tailwindcss-rails (2.6.1-arm-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.0-arm64-darwin)
|
||||
tailwindcss-rails (2.6.1-arm64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.0-x86_64-darwin)
|
||||
tailwindcss-rails (2.6.1-x86_64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.0-x86_64-linux)
|
||||
tailwindcss-rails (2.6.1-x86_64-linux)
|
||||
railties (>= 7.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
@@ -444,7 +446,7 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.6.15)
|
||||
zeitwerk (2.6.16)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
|
||||
@@ -35,6 +35,11 @@ There are 3 primary ways to use the Maybe app:
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
**If you are trying to _self-host_ the Maybe app, stop here. You
|
||||
should [read this guide to get started](docs/hosting/docker.md).**
|
||||
|
||||
The instructions below are for developers to get started with contributing to the app.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Ruby 3.3.1
|
||||
|
||||
@@ -7,31 +7,13 @@
|
||||
details > summary::-webkit-details-marker {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
details > summary {
|
||||
@apply list-none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.prose {
|
||||
table {
|
||||
@apply divide-y divide-gray-300;
|
||||
}
|
||||
|
||||
tr {
|
||||
@apply divide-x divide-gray-100;
|
||||
}
|
||||
|
||||
th {
|
||||
@apply whitespace-nowrap px-2 text-left text-sm font-semibold text-gray-900 py-3.5;
|
||||
}
|
||||
|
||||
tbody {
|
||||
@apply divide-y divide-gray-200;
|
||||
}
|
||||
|
||||
td {
|
||||
@apply whitespace-nowrap px-2 py-2 text-sm text-gray-500;
|
||||
}
|
||||
}
|
||||
|
||||
.form-field {
|
||||
@apply relative rounded-md border bg-white border-alpha-black-100 shadow-xs;
|
||||
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
||||
@@ -81,6 +63,20 @@
|
||||
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out;
|
||||
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
|
||||
}
|
||||
|
||||
.prose--github-release-notes {
|
||||
.octicon {
|
||||
@apply inline-block overflow-visible align-text-bottom fill-current;
|
||||
}
|
||||
|
||||
.dropdown-caret {
|
||||
@apply content-none border-4 border-b-0 border-transparent border-t-gray-500 size-0 inline-block;
|
||||
}
|
||||
|
||||
.user-mention {
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Small, single purpose classes that should take precedence over other styles */
|
||||
|
||||
105
app/controllers/account/entries_controller.rb
Normal file
105
app/controllers/account/entries_controller.rb
Normal file
@@ -0,0 +1,105 @@
|
||||
class Account::EntriesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: %i[ edit update show destroy ]
|
||||
|
||||
def transactions
|
||||
@transaction_entries = @account.entries.account_transactions.reverse_chronological
|
||||
end
|
||||
|
||||
def valuations
|
||||
@valuation_entries = @account.entries.account_valuations.reverse_chronological
|
||||
end
|
||||
|
||||
def new
|
||||
@entry = @account.entries.build.tap do |entry|
|
||||
if params[:entryable_type]
|
||||
entry.entryable = Account::Entryable.from_type(params[:entryable_type]).new
|
||||
else
|
||||
entry.entryable = Account::Valuation.new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
@entry = @account.entries.build(entry_params_with_defaults(entry_params))
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
redirect_to account_path(@account), notice: t(".success", name: @entry.entryable_name_short.upcase_first)
|
||||
else
|
||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
||||
flash[:error] = @entry.errors.full_messages.to_sentence
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@entry.assign_attributes entry_params
|
||||
@entry.amount = amount if nature.present?
|
||||
@entry.save!
|
||||
@entry.sync_account_later
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def destroy
|
||||
@entry.destroy!
|
||||
@entry.sync_account_later
|
||||
redirect_back_or_to account_url(@entry.account), notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = @account.entries.find(params[:id])
|
||||
end
|
||||
|
||||
def permitted_entryable_attributes
|
||||
entryable_type = @entry ? @entry.entryable_class.to_s : params[:account_entry][:entryable_type]
|
||||
|
||||
case entryable_type
|
||||
when "Account::Transaction"
|
||||
[ :id, :notes, :excluded, :category_id, :merchant_id, tag_ids: [] ]
|
||||
else
|
||||
[ :id ]
|
||||
end
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: permitted_entryable_attributes)
|
||||
end
|
||||
|
||||
def amount
|
||||
if nature.income?
|
||||
entry_params[:amount].to_d.abs * -1
|
||||
else
|
||||
entry_params[:amount].to_d.abs
|
||||
end
|
||||
end
|
||||
|
||||
def nature
|
||||
params[:account_entry][:nature].to_s.inquiry
|
||||
end
|
||||
|
||||
# entryable_type is required here because Rails expects both of these params in this exact order (potential upstream bug)
|
||||
def entry_params_with_defaults(params)
|
||||
params.with_defaults(entryable_type: params[:entryable_type], entryable_attributes: {})
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Accounts::LogosController < ApplicationController
|
||||
class Account::LogosController < ApplicationController
|
||||
def show
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
render_placeholder
|
||||
45
app/controllers/account/transfers_controller.rb
Normal file
45
app/controllers/account/transfers_controller.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
class Account::TransfersController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_transfer, only: :destroy
|
||||
|
||||
def new
|
||||
@transfer = Account::Transfer.new
|
||||
end
|
||||
|
||||
def create
|
||||
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
|
||||
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
|
||||
|
||||
@transfer = Account::Transfer.build_from_accounts from_account, to_account, \
|
||||
date: transfer_params[:date],
|
||||
amount: transfer_params[:amount].to_d,
|
||||
currency: transfer_params[:currency],
|
||||
name: transfer_params[:name]
|
||||
|
||||
if @transfer.save
|
||||
@transfer.entries.each(&:sync_account_later)
|
||||
redirect_to transactions_path, notice: t(".success")
|
||||
else
|
||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
||||
flash[:error] = @transfer.errors.full_messages.to_sentence
|
||||
redirect_to transactions_path
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transfer.destroy_and_remove_marks!
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_transfer
|
||||
@transfer = Account::Transfer.find(params[:id])
|
||||
end
|
||||
|
||||
def transfer_params
|
||||
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :currency, :date, :name)
|
||||
end
|
||||
end
|
||||
@@ -2,10 +2,12 @@ class AccountsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
include Filterable
|
||||
before_action :set_account, only: %i[ show update destroy sync ]
|
||||
before_action :set_account, only: %i[ edit show destroy sync update ]
|
||||
after_action :sync_account, only: :create
|
||||
|
||||
def index
|
||||
@accounts = Current.family.accounts
|
||||
@institutions = Current.family.institutions
|
||||
@accounts = Current.family.accounts.ungrouped.alphabetically
|
||||
end
|
||||
|
||||
def summary
|
||||
@@ -25,47 +27,33 @@ class AccountsController < ApplicationController
|
||||
balance: nil,
|
||||
accountable: Accountable.from_type(params[:type])&.new
|
||||
)
|
||||
|
||||
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)
|
||||
@valuation_series = @account.valuations.to_series
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @account.update(account_params.except(:accountable_type))
|
||||
|
||||
@account.sync_later if account_params[:is_active] == "1" && @account.can_sync?
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to accounts_path, notice: t(".success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: t(".success") } }),
|
||||
turbo_stream.replace("account_#{@account.id}", partial: "accounts/account", locals: { account: @account })
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
render "show", status: :unprocessable_entity
|
||||
end
|
||||
@account.update! account_params.except(:accountable_type)
|
||||
redirect_back_or_to account_path(@account), notice: t(".success")
|
||||
end
|
||||
|
||||
def create
|
||||
@account = Current.family.accounts.build(account_params.except(:accountable_type, :start_date))
|
||||
@account.accountable = Accountable.from_type(account_params[:accountable_type])&.new
|
||||
@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]
|
||||
|
||||
if @account.save
|
||||
@valuation = @account.valuations.new(date: account_params[:start_date] || Date.today, value: @account.balance, currency: @account.currency)
|
||||
@valuation.save!
|
||||
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
else
|
||||
render "new", status: :unprocessable_entity
|
||||
end
|
||||
redirect_back_or_to account_path(@account), notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -74,21 +62,26 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def sync
|
||||
if @account.can_sync?
|
||||
unless @account.syncing?
|
||||
@account.sync_later
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account), notice: t(".success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: t(".success") } })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
redirect_to account_path(@account), notice: t(".success")
|
||||
end
|
||||
|
||||
def sync_all
|
||||
synced_accounts_count = 0
|
||||
Current.family.accounts.each do |account|
|
||||
next unless account.can_sync?
|
||||
|
||||
account.sync_later
|
||||
synced_accounts_count += 1
|
||||
end
|
||||
|
||||
if synced_accounts_count > 0
|
||||
redirect_back_or_to accounts_path, notice: t(".success", count: synced_accounts_count)
|
||||
else
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account), notice: t(".cannot_sync") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "error", content: { body: t(".cannot_sync") } })
|
||||
end
|
||||
end
|
||||
redirect_back_or_to accounts_path, alert: t(".no_accounts_to_sync")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -99,6 +92,10 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :currency, :subtype, :is_active)
|
||||
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id)
|
||||
end
|
||||
|
||||
def sync_account
|
||||
@account.sync_later
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,20 +2,8 @@ class ApplicationController < ActionController::Base
|
||||
include Authentication, Invitable, SelfHostable
|
||||
include Pagy::Backend
|
||||
|
||||
before_action :sync_accounts
|
||||
|
||||
default_form_builder ApplicationFormBuilder
|
||||
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
allow_browser versions: :modern
|
||||
|
||||
private
|
||||
|
||||
def sync_accounts
|
||||
return if Current.user.blank?
|
||||
|
||||
if Current.user.last_login_at.nil? || Current.user.last_login_at.before?(Date.current.beginning_of_day)
|
||||
Current.family.sync_accounts
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
class Transactions::CategoriesController < ApplicationController
|
||||
class CategoriesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_category, only: %i[ edit update ]
|
||||
before_action :set_transaction, only: :create
|
||||
|
||||
def index
|
||||
@categories = Current.family.transaction_categories.alphabetically
|
||||
@categories = Current.family.categories.alphabetically
|
||||
end
|
||||
|
||||
def new
|
||||
@category = Current.family.transaction_categories.new color: Transaction::Category::COLORS.sample
|
||||
@category = Current.family.categories.new color: Category::COLORS.sample
|
||||
end
|
||||
|
||||
def create
|
||||
Transaction::Category.transaction do
|
||||
category = Current.family.transaction_categories.create!(category_params)
|
||||
Category.transaction do
|
||||
category = Current.family.categories.create!(category_params)
|
||||
@transaction.update!(category_id: category.id) if @transaction
|
||||
end
|
||||
|
||||
@@ -32,7 +32,7 @@ class Transactions::CategoriesController < ApplicationController
|
||||
|
||||
private
|
||||
def set_category
|
||||
@category = Current.family.transaction_categories.find(params[:id])
|
||||
@category = Current.family.categories.find(params[:id])
|
||||
end
|
||||
|
||||
def set_transaction
|
||||
@@ -42,6 +42,6 @@ class Transactions::CategoriesController < ApplicationController
|
||||
end
|
||||
|
||||
def category_params
|
||||
params.require(:transaction_category).permit(:name, :color)
|
||||
params.require(:category).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Transactions::Categories::DeletionsController < ApplicationController
|
||||
class Category::DeletionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_category
|
||||
@@ -15,12 +15,12 @@ class Transactions::Categories::DeletionsController < ApplicationController
|
||||
|
||||
private
|
||||
def set_category
|
||||
@category = Current.family.transaction_categories.find(params[:category_id])
|
||||
@category = Current.family.categories.find(params[:category_id])
|
||||
end
|
||||
|
||||
def set_replacement_category
|
||||
if params[:replacement_category_id].present?
|
||||
@replacement_category = Current.family.transaction_categories.find(params[:replacement_category_id])
|
||||
@replacement_category = Current.family.categories.find(params[:replacement_category_id])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Transactions::Categories::DropdownsController < ApplicationController
|
||||
class Category::DropdownsController < ApplicationController
|
||||
before_action :set_from_params
|
||||
|
||||
def show
|
||||
@@ -17,6 +17,6 @@ class Transactions::Categories::DropdownsController < ApplicationController
|
||||
end
|
||||
|
||||
def categories_scope
|
||||
Current.family.transaction_categories.alphabetically
|
||||
Current.family.categories.alphabetically
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,8 @@ class ImportsController < ApplicationController
|
||||
end
|
||||
|
||||
def new
|
||||
@import = Import.new
|
||||
account = Current.family.accounts.find_by(id: params[:account_id])
|
||||
@import = Import.new account: account
|
||||
end
|
||||
|
||||
def edit
|
||||
|
||||
35
app/controllers/institutions_controller.rb
Normal file
35
app/controllers/institutions_controller.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class InstitutionsController < ApplicationController
|
||||
before_action :set_institution, except: %i[ new create ]
|
||||
|
||||
def new
|
||||
@institution = Institution.new
|
||||
end
|
||||
|
||||
def create
|
||||
Current.family.institutions.create!(institution_params)
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@institution.update!(institution_params)
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@institution.destroy!
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def institution_params
|
||||
params.require(:institution).permit(:name, :logo)
|
||||
end
|
||||
|
||||
def set_institution
|
||||
@institution = Current.family.institutions.find(params[:id])
|
||||
end
|
||||
end
|
||||
41
app/controllers/merchants_controller.rb
Normal file
41
app/controllers/merchants_controller.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
class MerchantsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_merchant, only: %i[ edit update destroy ]
|
||||
|
||||
def index
|
||||
@merchants = Current.family.merchants.alphabetically
|
||||
end
|
||||
|
||||
def new
|
||||
@merchant = Merchant.new
|
||||
end
|
||||
|
||||
def create
|
||||
Current.family.merchants.create!(merchant_params)
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@merchant.update!(merchant_params)
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@merchant.destroy!
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_merchant
|
||||
@merchant = Current.family.merchants.find(params[:id])
|
||||
end
|
||||
|
||||
def merchant_params
|
||||
params.require(:merchant).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
@@ -21,7 +21,7 @@ class PagesController < ApplicationController
|
||||
|
||||
@accounts = Current.family.accounts
|
||||
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
@transactions = Current.family.transactions.limit(5).order(date: :desc)
|
||||
@transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological
|
||||
|
||||
# TODO: Placeholders for trendlines
|
||||
placeholder_series_data = 10.times.map do |i|
|
||||
@@ -31,6 +31,7 @@ class PagesController < ApplicationController
|
||||
end
|
||||
|
||||
def changelog
|
||||
@releases_notes = Provider::Github.new.fetch_latest_releases_notes
|
||||
end
|
||||
|
||||
def feedback
|
||||
|
||||
@@ -16,7 +16,7 @@ class RegistrationsController < ApplicationController
|
||||
@user.role = :admin
|
||||
|
||||
if @user.save
|
||||
Transaction::Category.create_default_categories(@user.family)
|
||||
Category.create_default_categories(@user.family)
|
||||
login @user
|
||||
flash[:notice] = t(".success")
|
||||
redirect_to root_path
|
||||
|
||||
@@ -47,7 +47,7 @@ class Settings::HostingsController < SettingsController
|
||||
end
|
||||
end
|
||||
|
||||
if hosting_params[:upgrades_mode] != "manual" && hosting_params[:render_deploy_hook].blank?
|
||||
if hosting_params[:upgrades_mode] == "auto" && hosting_params[:render_deploy_hook].blank?
|
||||
@errors.add(:render_deploy_hook, t("settings.hostings.update.render_deploy_hook_error"))
|
||||
end
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Tags::DeletionsController < ApplicationController
|
||||
class Tag::DeletionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_tag
|
||||
@@ -1,41 +0,0 @@
|
||||
class Transactions::MerchantsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_merchant, only: %i[ edit update destroy ]
|
||||
|
||||
def index
|
||||
@merchants = Current.family.transaction_merchants.alphabetically
|
||||
end
|
||||
|
||||
def new
|
||||
@merchant = Transaction::Merchant.new
|
||||
end
|
||||
|
||||
def create
|
||||
Current.family.transaction_merchants.create!(merchant_params)
|
||||
redirect_to transaction_merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@merchant.update!(merchant_params)
|
||||
redirect_to transaction_merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@merchant.destroy!
|
||||
redirect_to transaction_merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_merchant
|
||||
@merchant = Current.family.transaction_merchants.find(params[:id])
|
||||
end
|
||||
|
||||
def merchant_params
|
||||
params.require(:transaction_merchant).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
@@ -1,22 +0,0 @@
|
||||
class Transactions::RowsController < ApplicationController
|
||||
before_action :set_transaction, only: %i[ show update ]
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def update
|
||||
@transaction.update! transaction_params
|
||||
|
||||
redirect_to transaction_row_path(@transaction)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def transaction_params
|
||||
params.require(:transaction).permit(:category_id)
|
||||
end
|
||||
|
||||
def set_transaction
|
||||
@transaction = Current.family.transactions.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -1,6 +0,0 @@
|
||||
class Transactions::RulesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
def index
|
||||
end
|
||||
end
|
||||
@@ -1,106 +1,104 @@
|
||||
class TransactionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_transaction, only: %i[ show edit update destroy ]
|
||||
|
||||
def index
|
||||
@q = search_params
|
||||
result = Current.family.transactions.search(@q).ordered
|
||||
@pagy, @transactions = pagy(result, items: 50)
|
||||
result = Current.family.entries.account_transactions.search(@q).reverse_chronological
|
||||
@pagy, @transaction_entries = pagy(result, items: params[:per_page] || "50")
|
||||
|
||||
@totals = {
|
||||
count: result.count,
|
||||
income: result.inflows.sum(&:amount_money).abs,
|
||||
expense: result.outflows.sum(&:amount_money).abs
|
||||
count: result.select { |t| t.currency == Current.family.currency }.count,
|
||||
income: result.income_total(Current.family.currency).abs,
|
||||
expense: result.expense_total(Current.family.currency)
|
||||
}
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def new
|
||||
@transaction = Transaction.new.tap do |txn|
|
||||
@entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e|
|
||||
if params[:account_id]
|
||||
txn.account = Current.family.accounts.find(params[:account_id])
|
||||
e.account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def create
|
||||
@transaction = Current.family.accounts
|
||||
.find(params[:transaction][:account_id])
|
||||
.transactions.build(transaction_params.merge(amount: amount))
|
||||
@entry = Current.family
|
||||
.accounts
|
||||
.find(params[:account_entry][:account_id])
|
||||
.entries
|
||||
.create!(transaction_entry_params.merge(amount: amount))
|
||||
|
||||
@transaction.save!
|
||||
@transaction.sync_account_later
|
||||
redirect_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def update
|
||||
@transaction.update! transaction_params
|
||||
@transaction.sync_account_later
|
||||
|
||||
redirect_to transaction_url(@transaction), notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transaction.destroy!
|
||||
@transaction.sync_account_later
|
||||
redirect_to transactions_url, notice: t(".success")
|
||||
@entry.sync_account_later
|
||||
redirect_back_or_to account_path(@entry.account), notice: t(".success")
|
||||
end
|
||||
|
||||
def bulk_delete
|
||||
destroyed = Current.family.transactions.destroy_by(id: bulk_delete_params[:transaction_ids])
|
||||
redirect_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||
end
|
||||
|
||||
def bulk_edit
|
||||
end
|
||||
|
||||
def bulk_update
|
||||
transactions = Current.family.transactions.where(id: bulk_update_params[:transaction_ids])
|
||||
if transactions.update_all(bulk_update_params.except(:transaction_ids).to_h.compact_blank!)
|
||||
redirect_to transactions_url, notice: t(".success", count: transactions.count)
|
||||
else
|
||||
flash.now[:error] = t(".failure")
|
||||
render :index, status: :unprocessable_entity
|
||||
end
|
||||
updated = Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.bulk_update!(bulk_update_params)
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
|
||||
end
|
||||
|
||||
def mark_transfers
|
||||
Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.mark_transfers!
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def unmark_transfers
|
||||
Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.update_all marked_as_transfer: false
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def rules
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_transaction
|
||||
@transaction = Current.family.transactions.find(params[:id])
|
||||
end
|
||||
|
||||
def amount
|
||||
if nature.income?
|
||||
transaction_params[:amount].to_d * -1
|
||||
transaction_entry_params[:amount].to_d * -1
|
||||
else
|
||||
transaction_params[:amount].to_d
|
||||
transaction_entry_params[:amount].to_d
|
||||
end
|
||||
end
|
||||
|
||||
def nature
|
||||
params[:transaction][:nature].to_s.inquiry
|
||||
params[:account_entry][:nature].to_s.inquiry
|
||||
end
|
||||
|
||||
def bulk_delete_params
|
||||
params.require(:bulk_delete).permit(transaction_ids: [])
|
||||
params.require(:bulk_delete).permit(entry_ids: [])
|
||||
end
|
||||
|
||||
def bulk_update_params
|
||||
params.require(:bulk_update).permit(:date, :notes, :excluded, :category_id, :merchant_id, transaction_ids: [])
|
||||
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: [])
|
||||
end
|
||||
|
||||
def transaction_params
|
||||
params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id, tag_ids: [])
|
||||
def transaction_entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: [ :category_id ])
|
||||
.with_defaults(entryable_type: "Account::Transaction", entryable_attributes: {})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
class ValuationsController < ApplicationController
|
||||
before_action :set_valuation, only: %i[ edit update destroy ]
|
||||
def create
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
|
||||
# TODO: placeholder logic until we have a better abstraction for trends
|
||||
@valuation = @account.valuations.new(valuation_params.merge(currency: @account.currency))
|
||||
if @valuation.save
|
||||
@valuation.account.sync_later(@valuation.date)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account), notice: "Valuation created" }
|
||||
format.turbo_stream
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
flash.now[:error] = "Valuation already exists for this date"
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def show
|
||||
@valuation = Current.family.accounts.find(params[:account_id]).valuations.find(params[:id])
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
sync_start_date = [ @valuation.date, Date.parse(valuation_params[:date]) ].compact.min
|
||||
if @valuation.update(valuation_params)
|
||||
@valuation.account.sync_later(sync_start_date)
|
||||
|
||||
redirect_to account_path(@valuation.account), notice: "Valuation updated"
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
flash.now[:error] = "Valuation already exists for this date"
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
@account = @valuation.account
|
||||
sync_start_date = @account.valuations.where("date < ?", @valuation.date).order(date: :desc).first&.date
|
||||
@valuation.destroy!
|
||||
@account.sync_later(sync_start_date)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account), notice: "Valuation deleted" }
|
||||
format.turbo_stream
|
||||
end
|
||||
end
|
||||
|
||||
def new
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
@valuation = @account.valuations.new
|
||||
end
|
||||
|
||||
private
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_valuation
|
||||
@valuation = Valuation.find(params[:id])
|
||||
end
|
||||
|
||||
def valuation_params
|
||||
params.require(:valuation).permit(:date, :value)
|
||||
end
|
||||
end
|
||||
39
app/helpers/account/entries_helper.rb
Normal file
39
app/helpers/account/entries_helper.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
module Account::EntriesHelper
|
||||
def permitted_entryable_partial_path(entry, relative_partial_path)
|
||||
"account/entries/entryables/#{permitted_entryable_key(entry)}/#{relative_partial_path}"
|
||||
end
|
||||
|
||||
def unconfirmed_transfer?(entry)
|
||||
entry.marked_as_transfer? && entry.transfer.nil?
|
||||
end
|
||||
|
||||
def transfer_entries(entries)
|
||||
transfers = entries.select { |e| e.transfer_id.present? }
|
||||
transfers.map(&:transfer).uniq
|
||||
end
|
||||
|
||||
def entry_icon(entry, is_oldest: false)
|
||||
if is_oldest
|
||||
"keyboard"
|
||||
elsif entry.trend.direction.up?
|
||||
"arrow-up"
|
||||
elsif entry.trend.direction.down?
|
||||
"arrow-down"
|
||||
else
|
||||
"minus"
|
||||
end
|
||||
end
|
||||
|
||||
def entry_style(entry, is_oldest: false)
|
||||
color = is_oldest ? "#D444F1" : entry.trend.color
|
||||
|
||||
mixed_hex_styles(color)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permitted_entryable_key(entry)
|
||||
permitted_entryable_paths = %w[transaction valuation]
|
||||
entry.entryable_name_short.presence_in(permitted_entryable_paths)
|
||||
end
|
||||
end
|
||||
2
app/helpers/account/transfers_helper.rb
Normal file
2
app/helpers/account/transfers_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module Account::TransfersHelper
|
||||
end
|
||||
@@ -27,14 +27,14 @@ module AccountsHelper
|
||||
|
||||
def class_mapping(accountable_type)
|
||||
{
|
||||
"Account::Credit" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
|
||||
"Account::Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
|
||||
"Account::OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
|
||||
"Account::Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
|
||||
"Account::Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
|
||||
"Account::OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
|
||||
"Account::Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
|
||||
"Account::Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
|
||||
"CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
|
||||
"Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
|
||||
"OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
|
||||
"Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
|
||||
"Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
|
||||
"OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
|
||||
"Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
|
||||
"Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
|
||||
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,7 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
|
||||
# See `Monetizable` concern, which adds a _money suffix to the attribute name
|
||||
# For a monetized field, the setter will always be the attribute name without the _money suffix
|
||||
def money_field(method, options = {})
|
||||
money = @object.send(method)
|
||||
money = @object && @object.respond_to?(method) ? @object.send(method) : nil
|
||||
raise ArgumentError, "The value of #{method} is not a Money object" unless money.is_a?(Money) || money.nil?
|
||||
|
||||
money_amount_method = method.to_s.chomp("_money").to_sym
|
||||
|
||||
@@ -65,6 +65,20 @@ module ApplicationHelper
|
||||
end
|
||||
end
|
||||
|
||||
def mixed_hex_styles(hex)
|
||||
color = hex || "#1570EF" # blue-600
|
||||
|
||||
<<-STYLE.strip
|
||||
background-color: color-mix(in srgb, #{color} 5%, white);
|
||||
border-color: color-mix(in srgb, #{color} 10%, white);
|
||||
color: #{color};
|
||||
STYLE
|
||||
end
|
||||
|
||||
def circle_logo(name, hex: nil, size: "md")
|
||||
render partial: "shared/circle_logo", locals: { name: name, hex: hex, size: size }
|
||||
end
|
||||
|
||||
def return_to_path(params, fallback = root_path)
|
||||
uri = URI.parse(params[:return_to] || fallback)
|
||||
uri.relative? ? uri.path : root_path
|
||||
@@ -122,4 +136,11 @@ module ApplicationHelper
|
||||
options.reverse_merge!(money.default_format_options)
|
||||
ActiveSupport::NumberHelper.number_to_delimited(money.amount.round(options[:precision] || 0), { delimiter: options[:delimiter], separator: options[:separator] })
|
||||
end
|
||||
|
||||
def totals_by_currency(collection:, money_method:, separator: " | ", negate: false)
|
||||
collection.group_by(&:currency)
|
||||
.transform_values { |item| negate ? item.sum(&money_method) * -1 : item.sum(&money_method) }
|
||||
.map { |_currency, money| format_money(money) }
|
||||
.join(separator)
|
||||
end
|
||||
end
|
||||
|
||||
7
app/helpers/categories_helper.rb
Normal file
7
app/helpers/categories_helper.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
module CategoriesHelper
|
||||
def null_category
|
||||
Category.new \
|
||||
name: "Uncategorized",
|
||||
color: Category::UNCATEGORIZED_COLOR
|
||||
end
|
||||
end
|
||||
@@ -11,6 +11,22 @@ module FormsHelper
|
||||
end
|
||||
end
|
||||
|
||||
def selectable_categories
|
||||
Current.family.categories.alphabetically
|
||||
end
|
||||
|
||||
def selectable_merchants
|
||||
Current.family.merchants.alphabetically
|
||||
end
|
||||
|
||||
def selectable_accounts
|
||||
Current.family.accounts.alphabetically
|
||||
end
|
||||
|
||||
def selectable_tags
|
||||
Current.family.tags.alphabetically.pluck(:name, :id)
|
||||
end
|
||||
|
||||
private
|
||||
def radio_tab_contents(label:, icon:)
|
||||
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm") do
|
||||
|
||||
5
app/helpers/institutions_helper.rb
Normal file
5
app/helpers/institutions_helper.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
module InstitutionsHelper
|
||||
def institution_logo(institution)
|
||||
institution.logo.attached? ? institution.logo : institution.logo_url
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,23 @@ module MenusHelper
|
||||
end
|
||||
end
|
||||
|
||||
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: nil)
|
||||
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"))
|
||||
end
|
||||
end
|
||||
|
||||
def contextual_menu_destructive_item(label, url, turbo_confirm: true, turbo_frame: nil)
|
||||
button_to url,
|
||||
method: :delete,
|
||||
class: "flex items-center w-full rounded-lg text-red-500 hover:bg-red-500/5 py-2 px-3 gap-2",
|
||||
data: { turbo_confirm: turbo_confirm, turbo_frame: } do
|
||||
concat(lucide_icon("trash-2", class: "shrink-0 w-5 h-5"))
|
||||
concat(tag.span(label, class: "text-sm"))
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def contextual_menu_icon
|
||||
tag.button class: "flex hover:bg-gray-100 p-2 rounded", data: { menu_target: "button" } do
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
module Transactions::CategoriesHelper
|
||||
def null_category
|
||||
Transaction::Category.new \
|
||||
name: "Uncategorized",
|
||||
color: Transaction::Category::UNCATEGORIZED_COLOR
|
||||
end
|
||||
end
|
||||
@@ -1,37 +0,0 @@
|
||||
module Transactions::SearchesHelper
|
||||
def transaction_search_filters
|
||||
[
|
||||
{ key: "account_filter", name: "Account", icon: "layers" },
|
||||
{ key: "date_filter", name: "Date", icon: "calendar" },
|
||||
{ key: "type_filter", name: "Type", icon: "shapes" },
|
||||
{ key: "amount_filter", name: "Amount", icon: "hash" },
|
||||
{ key: "category_filter", name: "Category", icon: "tag" },
|
||||
{ key: "merchant_filter", name: "Merchant", icon: "store" }
|
||||
]
|
||||
end
|
||||
|
||||
def get_transaction_search_filter_partial_path(filter)
|
||||
"transactions/searches/filters/#{filter[:key]}"
|
||||
end
|
||||
|
||||
def get_default_transaction_search_filter
|
||||
transaction_search_filters[0]
|
||||
end
|
||||
|
||||
def transactions_path_without_param(param_key, param_value)
|
||||
updated_params = request.query_parameters.deep_dup
|
||||
|
||||
q_params = updated_params[:q] || {}
|
||||
|
||||
current_value = q_params[param_key]
|
||||
if current_value.is_a?(Array)
|
||||
q_params[param_key] = current_value - [ param_value ]
|
||||
else
|
||||
q_params.delete(param_key)
|
||||
end
|
||||
|
||||
updated_params[:q] = q_params
|
||||
|
||||
transactions_path(updated_params)
|
||||
end
|
||||
end
|
||||
@@ -1,20 +1,37 @@
|
||||
module TransactionsHelper
|
||||
def transactions_group(date, transactions, transaction_partial_path = "transactions/transaction")
|
||||
header_left = content_tag :span do
|
||||
"#{date.strftime('%b %d, %Y')} · #{transactions.size}".html_safe
|
||||
def transaction_search_filters
|
||||
[
|
||||
{ key: "account_filter", name: "Account", icon: "layers" },
|
||||
{ key: "date_filter", name: "Date", icon: "calendar" },
|
||||
{ key: "type_filter", name: "Type", icon: "shapes" },
|
||||
{ key: "amount_filter", name: "Amount", icon: "hash" },
|
||||
{ key: "category_filter", name: "Category", icon: "tag" },
|
||||
{ key: "merchant_filter", name: "Merchant", icon: "store" }
|
||||
]
|
||||
end
|
||||
|
||||
def get_transaction_search_filter_partial_path(filter)
|
||||
"transactions/searches/filters/#{filter[:key]}"
|
||||
end
|
||||
|
||||
def get_default_transaction_search_filter
|
||||
transaction_search_filters[0]
|
||||
end
|
||||
|
||||
def transactions_path_without_param(param_key, param_value)
|
||||
updated_params = request.query_parameters.deep_dup
|
||||
|
||||
q_params = updated_params[:q] || {}
|
||||
|
||||
current_value = q_params[param_key]
|
||||
if current_value.is_a?(Array)
|
||||
q_params[param_key] = current_value - [ param_value ]
|
||||
else
|
||||
q_params.delete(param_key)
|
||||
end
|
||||
|
||||
header_right = content_tag :span do
|
||||
format_money(-transactions.sum(&:amount_money))
|
||||
end
|
||||
updated_params[:q] = q_params
|
||||
|
||||
header = header_left.concat(header_right)
|
||||
|
||||
content = render partial: transaction_partial_path, collection: transactions
|
||||
|
||||
render partial: "shared/list_group", locals: {
|
||||
header: header,
|
||||
content: content
|
||||
}
|
||||
transactions_path(updated_params)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
module ValuationsHelper
|
||||
end
|
||||
@@ -25,7 +25,7 @@ export default class extends Controller {
|
||||
submitBulkRequest(e) {
|
||||
const form = e.target.closest("form");
|
||||
const scope = e.params.scope
|
||||
this.#addHiddenFormInputsForSelectedIds(form, `${scope}[transaction_ids][]`, this.selectedIdsValue)
|
||||
this.#addHiddenFormInputsForSelectedIds(form, `${scope}[entry_ids][]`, this.selectedIdsValue)
|
||||
form.requestSubmit()
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
#addHiddenFormInputsForSelectedIds(form, paramName, transactionIds) {
|
||||
this.#resetFormInputs(form, paramName);
|
||||
|
||||
transactionIds.forEach(id => {
|
||||
const input = document.createElement("input");
|
||||
input.type = 'hidden'
|
||||
@@ -75,6 +77,11 @@ export default class extends Controller {
|
||||
})
|
||||
}
|
||||
|
||||
#resetFormInputs(form, paramName) {
|
||||
const existingInputs = form.querySelectorAll(`input[name='${paramName}']`);
|
||||
existingInputs.forEach((input) => input.remove());
|
||||
}
|
||||
|
||||
#rowsForGroup(group) {
|
||||
return this.rowTargets.filter(row => group.contains(row))
|
||||
}
|
||||
@@ -113,7 +120,7 @@ export default class extends Controller {
|
||||
#updateGroups() {
|
||||
this.groupTargets.forEach(group => {
|
||||
const rows = this.rowTargets.filter(row => group.contains(row))
|
||||
const groupSelected = rows.every(row => this.selectedIdsValue.includes(row.dataset.id))
|
||||
const groupSelected = rows.length > 0 && rows.every(row => this.selectedIdsValue.includes(row.dataset.id))
|
||||
group.querySelector("input[type='checkbox']").checked = groupSelected
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export default class extends Controller {
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-24 h-24 rounded-full object-cover" />`;
|
||||
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-full h-full rounded-full object-cover" />`;
|
||||
this.templateTarget.classList.add("hidden");
|
||||
this.clearBtnTarget.classList.remove("hidden");
|
||||
};
|
||||
|
||||
@@ -2,23 +2,29 @@ class Account < ApplicationRecord
|
||||
include Syncable
|
||||
include Monetizable
|
||||
|
||||
broadcasts_refreshes
|
||||
|
||||
validates :family, presence: true
|
||||
|
||||
broadcasts_refreshes
|
||||
belongs_to :family
|
||||
belongs_to :institution, optional: true
|
||||
|
||||
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
|
||||
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
|
||||
has_many :balances, dependent: :destroy
|
||||
has_many :valuations, dependent: :destroy
|
||||
has_many :transactions, dependent: :destroy
|
||||
has_many :imports, dependent: :destroy
|
||||
|
||||
monetize :balance
|
||||
|
||||
enum :status, { ok: "ok", syncing: "syncing", error: "error" }, validate: true
|
||||
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
|
||||
|
||||
scope :active, -> { where(is_active: true) }
|
||||
scope :assets, -> { where(classification: "asset") }
|
||||
scope :liabilities, -> { where(classification: "liability") }
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
scope :ungrouped, -> { where(institution_id: nil) }
|
||||
|
||||
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
||||
|
||||
@@ -26,10 +32,13 @@ class Account < ApplicationRecord
|
||||
balances.where("date <= ?", date).order(date: :desc).first&.balance
|
||||
end
|
||||
|
||||
def favorable_direction
|
||||
classification == "asset" ? "up" : "down"
|
||||
end
|
||||
|
||||
# e.g. Wise, Revolut accounts that have transactions in multiple currencies
|
||||
def multi_currency?
|
||||
currencies = [ valuations.pluck(:currency), transactions.pluck(:currency) ].flatten.uniq
|
||||
currencies.count > 1
|
||||
entries.select(:currency).distinct.count > 1
|
||||
end
|
||||
|
||||
# e.g. Accounts denominated in currency other than family currency
|
||||
@@ -37,16 +46,6 @@ class Account < ApplicationRecord
|
||||
currency != family.currency
|
||||
end
|
||||
|
||||
def self.by_provider
|
||||
# TODO: When 3rd party providers are supported, dynamically load all providers and their accounts
|
||||
[ { name: "Manual accounts", accounts: all.order(balance: :desc).group_by(&:accountable_type) } ]
|
||||
end
|
||||
|
||||
def self.some_syncing?
|
||||
exists?(status: "syncing")
|
||||
end
|
||||
|
||||
|
||||
def series(period: Period.all, currency: self.currency)
|
||||
balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code)
|
||||
|
||||
@@ -80,4 +79,28 @@ class Account < ApplicationRecord
|
||||
|
||||
grouped_accounts
|
||||
end
|
||||
|
||||
def self.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
|
||||
|
||||
# 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?
|
||||
account.entries.build \
|
||||
date: start_date,
|
||||
amount: start_balance,
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
|
||||
account.save!
|
||||
account
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,123 +1,115 @@
|
||||
class Account::Balance::Calculator
|
||||
attr_reader :daily_balances, :errors, :warnings
|
||||
attr_reader :errors, :warnings
|
||||
|
||||
def initialize(account, options = {})
|
||||
@daily_balances = []
|
||||
@errors = []
|
||||
@warnings = []
|
||||
@account = account
|
||||
@calc_start_date = [ options[:calc_start_date], @account.effective_start_date ].compact.max
|
||||
def initialize(account, options = {})
|
||||
@errors = []
|
||||
@warnings = []
|
||||
@account = account
|
||||
@calc_start_date = calculate_sync_start(options[:calc_start_date])
|
||||
end
|
||||
|
||||
def daily_balances
|
||||
@daily_balances ||= calculate_daily_balances
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :calc_start_date, :account
|
||||
|
||||
def calculate_sync_start(provided_start_date = nil)
|
||||
if account.balances.any?
|
||||
[ provided_start_date, account.effective_start_date ].compact.max
|
||||
else
|
||||
account.effective_start_date
|
||||
end
|
||||
end
|
||||
|
||||
def calculate
|
||||
prior_balance = implied_start_balance
|
||||
def calculate_daily_balances
|
||||
prior_balance = nil
|
||||
|
||||
calculated_balances = ((@calc_start_date + 1.day)..Date.current).map do |date|
|
||||
valuation = normalized_valuations.find { |v| v["date"] == date }
|
||||
calculated_balances = (calc_start_date..Date.current).map do |date|
|
||||
valuation_entry = find_valuation_entry(date)
|
||||
|
||||
if valuation
|
||||
current_balance = valuation["value"]
|
||||
if valuation_entry
|
||||
current_balance = valuation_entry.amount
|
||||
elsif prior_balance.nil?
|
||||
current_balance = implied_start_balance
|
||||
else
|
||||
txn_flows = transaction_flows(date)
|
||||
txn_entries = syncable_transaction_entries.select { |e| e.date == date }
|
||||
txn_flows = transaction_flows(txn_entries)
|
||||
current_balance = prior_balance - txn_flows
|
||||
end
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
{ date:, balance: current_balance, currency: @account.currency, updated_at: Time.current }
|
||||
{ date:, balance: current_balance, currency: account.currency, updated_at: Time.current }
|
||||
end
|
||||
|
||||
@daily_balances = [
|
||||
{ date: @calc_start_date, balance: implied_start_balance, currency: @account.currency, updated_at: Time.current },
|
||||
*calculated_balances
|
||||
]
|
||||
|
||||
if @account.foreign_currency?
|
||||
converted_balances = convert_balances_to_family_currency
|
||||
@daily_balances.concat(converted_balances)
|
||||
if account.foreign_currency?
|
||||
calculated_balances.concat(convert_balances_to_family_currency(calculated_balances))
|
||||
end
|
||||
|
||||
self
|
||||
calculated_balances
|
||||
end
|
||||
|
||||
private
|
||||
def convert_balances_to_family_currency
|
||||
rates = ExchangeRate.get_rates(
|
||||
@account.currency,
|
||||
@account.family.currency,
|
||||
@calc_start_date..Date.current
|
||||
).to_a
|
||||
def syncable_entries
|
||||
@entries ||= account.entries.where("date >= ?", calc_start_date).to_a
|
||||
end
|
||||
|
||||
# Abort conversion if some required rates are missing
|
||||
if rates.length != @daily_balances.length
|
||||
@errors << :sync_message_missing_rates
|
||||
return []
|
||||
end
|
||||
def syncable_transaction_entries
|
||||
@syncable_transaction_entries ||= syncable_entries.select { |e| e.account_transaction? }
|
||||
end
|
||||
|
||||
@daily_balances.map.with_index do |balance, index|
|
||||
converted_balance = balance[:balance] * rates[index].rate
|
||||
{ date: balance[:date], balance: converted_balance, currency: @account.family.currency, updated_at: Time.current }
|
||||
end
|
||||
def find_valuation_entry(date)
|
||||
syncable_entries.find { |entry| entry.date == date && entry.account_valuation? }
|
||||
end
|
||||
|
||||
def transaction_flows(transaction_entries)
|
||||
converted_entries = transaction_entries.map { |entry| convert_entry_to_account_currency(entry) }.compact
|
||||
flows = converted_entries.sum(&:amount)
|
||||
flows *= -1 if account.liability?
|
||||
flows
|
||||
end
|
||||
|
||||
def convert_balances_to_family_currency(balances)
|
||||
rates = ExchangeRate.get_rates(
|
||||
account.currency,
|
||||
account.family.currency,
|
||||
calc_start_date..Date.current
|
||||
).to_a
|
||||
|
||||
# Abort conversion if some required rates are missing
|
||||
if rates.length != balances.length
|
||||
@errors << :sync_message_missing_rates
|
||||
return []
|
||||
end
|
||||
|
||||
# For calculation, all transactions and valuations need to be normalized to the same currency (the account's primary currency)
|
||||
def normalize_entries_to_account_currency(entries, value_key)
|
||||
grouped_entries = entries.group_by(&:currency)
|
||||
normalized_entries = []
|
||||
balances.map.with_index do |balance, index|
|
||||
converted_balance = balance[:balance] * rates[index].rate
|
||||
{ date: balance[:date], balance: converted_balance, currency: account.family.currency, updated_at: Time.current }
|
||||
end
|
||||
end
|
||||
|
||||
grouped_entries.each do |currency, entries|
|
||||
if currency != @account.currency
|
||||
dates = entries.map(&:date).uniq
|
||||
rates = ExchangeRate.get_rates(currency, @account.currency, dates).to_a
|
||||
if rates.length != dates.length
|
||||
@errors << :sync_message_missing_rates
|
||||
else
|
||||
entries.each do |entry|
|
||||
## There can be several entries on the same date so we cannot rely on indeces
|
||||
rate = rates.find { |rate| rate.date == entry.date }
|
||||
value = entry.send(value_key)
|
||||
value *= rate.rate
|
||||
normalized_entries << entry.attributes.merge(value_key.to_s => value, "currency" => currency)
|
||||
end
|
||||
end
|
||||
else
|
||||
normalized_entries.concat(entries)
|
||||
end
|
||||
end
|
||||
# Multi-currency accounts have transactions in many currencies
|
||||
def convert_entry_to_account_currency(entry)
|
||||
return entry if entry.currency == account.currency
|
||||
|
||||
normalized_entries
|
||||
converted_entry = entry.dup
|
||||
|
||||
rate = ExchangeRate.find_rate(from: entry.currency, to: account.currency, date: entry.date)
|
||||
|
||||
unless rate
|
||||
@errors << :sync_message_missing_rates
|
||||
return nil
|
||||
end
|
||||
|
||||
def normalized_valuations
|
||||
@normalized_valuations ||= normalize_entries_to_account_currency(@account.valuations.where("date >= ?", @calc_start_date).order(:date).select(:date, :value, :currency), :value)
|
||||
end
|
||||
converted_entry.currency = account.currency
|
||||
converted_entry.amount = entry.amount * rate.rate
|
||||
converted_entry
|
||||
end
|
||||
|
||||
def normalized_transactions
|
||||
@normalized_transactions ||= normalize_entries_to_account_currency(@account.transactions.where("date >= ?", @calc_start_date).order(:date).select(:date, :amount, :currency), :amount)
|
||||
end
|
||||
|
||||
def transaction_flows(date)
|
||||
flows = normalized_transactions.select { |t| t["date"] == date }.sum { |t| t["amount"] }
|
||||
flows *= -1 if @account.classification == "liability"
|
||||
flows
|
||||
end
|
||||
|
||||
def implied_start_balance
|
||||
if @calc_start_date > @account.effective_start_date
|
||||
return @account.balance_on(@calc_start_date)
|
||||
end
|
||||
|
||||
oldest_valuation_date = normalized_valuations.first&.date
|
||||
oldest_transaction_date = normalized_transactions.first&.date
|
||||
oldest_entry_date = [ oldest_valuation_date, oldest_transaction_date ].compact.min
|
||||
|
||||
if oldest_entry_date.present? && oldest_entry_date == oldest_valuation_date
|
||||
oldest_valuation = normalized_valuations.find { |v| v["date"] == oldest_valuation_date }
|
||||
oldest_valuation["value"].to_d
|
||||
else
|
||||
net_transaction_flows = normalized_transactions.sum { |t| t["amount"].to_d }
|
||||
net_transaction_flows *= -1 if @account.classification == "liability"
|
||||
@account.balance.to_d + net_transaction_flows
|
||||
end
|
||||
end
|
||||
def implied_start_balance
|
||||
transaction_entries = syncable_transaction_entries.select { |e| e.date > calc_start_date }
|
||||
account.balance.to_d + transaction_flows(transaction_entries)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::Credit < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::Crypto < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::Depository < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
196
app/models/account/entry.rb
Normal file
196
app/models/account/entry.rb
Normal file
@@ -0,0 +1,196 @@
|
||||
class Account::Entry < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
monetize :amount
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :transfer, optional: true
|
||||
|
||||
delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy
|
||||
accepts_nested_attributes_for :entryable
|
||||
|
||||
validates :date, :amount, :currency, presence: true
|
||||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
|
||||
|
||||
scope :chronological, -> { order(:date, :created_at) }
|
||||
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
|
||||
scope :without_transfers, -> { where(marked_as_transfer: false) }
|
||||
scope :with_converted_amount, ->(currency) {
|
||||
# Join with exchange rates to convert the amount to the given currency
|
||||
# If no rate is available, exclude the transaction from the results
|
||||
select(
|
||||
"account_entries.*",
|
||||
"account_entries.amount * COALESCE(er.rate, 1) AS converted_amount"
|
||||
)
|
||||
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.base_currency AND er.converted_currency = ?", currency ]))
|
||||
.where("er.rate IS NOT NULL OR account_entries.currency = ?", currency)
|
||||
}
|
||||
|
||||
def sync_account_later
|
||||
if destroyed?
|
||||
sync_start_date = previous_entry&.date
|
||||
else
|
||||
sync_start_date = [ date_previously_was, date ].compact.min
|
||||
end
|
||||
|
||||
account.sync_later(sync_start_date)
|
||||
end
|
||||
|
||||
def inflow?
|
||||
amount <= 0 && account_transaction?
|
||||
end
|
||||
|
||||
def outflow?
|
||||
amount > 0 && account_transaction?
|
||||
end
|
||||
|
||||
def first_of_type?
|
||||
first_entry = account
|
||||
.entries
|
||||
.where("entryable_type = ?", entryable_type)
|
||||
.order(:date)
|
||||
.first
|
||||
|
||||
first_entry&.id == id
|
||||
end
|
||||
|
||||
def entryable_name_short
|
||||
entryable_type.demodulize.underscore
|
||||
end
|
||||
|
||||
def trend
|
||||
@trend ||= create_trend
|
||||
end
|
||||
|
||||
class << self
|
||||
def daily_totals(entries, currency, period: Period.last_30_days)
|
||||
# Sum spending and income for each day in the period with the given currency
|
||||
select(
|
||||
"gs.date",
|
||||
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
|
||||
)
|
||||
.from(entries.with_converted_amount(currency), :e)
|
||||
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON e.date = gs.date", period.date_range.first, period.date_range.last ]))
|
||||
.group("gs.date")
|
||||
end
|
||||
|
||||
def daily_rolling_totals(entries, currency, period: Period.last_30_days)
|
||||
# Extend the period to include the rolling window
|
||||
period_with_rolling = period.extend_backward(period.date_range.count.days)
|
||||
|
||||
# Aggregate the rolling sum of spending and income based on daily totals
|
||||
rolling_totals = from(daily_totals(entries, currency, period: period_with_rolling))
|
||||
.select(
|
||||
"*",
|
||||
sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
|
||||
sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
|
||||
)
|
||||
.order(:date)
|
||||
|
||||
# Trim the results to the original period
|
||||
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
|
||||
end
|
||||
|
||||
def mark_transfers!
|
||||
update_all marked_as_transfer: true
|
||||
|
||||
# Attempt to "auto match" and save a transfer if 2 transactions selected
|
||||
Account::Transfer.new(entries: all).save if all.count == 2
|
||||
end
|
||||
|
||||
def bulk_update!(bulk_update_params)
|
||||
bulk_attributes = {
|
||||
date: bulk_update_params[:date],
|
||||
entryable_attributes: {
|
||||
notes: bulk_update_params[:notes],
|
||||
category_id: bulk_update_params[:category_id],
|
||||
merchant_id: bulk_update_params[:merchant_id]
|
||||
}.compact_blank
|
||||
}.compact_blank
|
||||
|
||||
return 0 if bulk_attributes.blank?
|
||||
|
||||
transaction do
|
||||
all.each do |entry|
|
||||
bulk_attributes[:entryable_attributes][:id] = entry.entryable_id if bulk_attributes[:entryable_attributes].present?
|
||||
entry.update! bulk_attributes
|
||||
end
|
||||
end
|
||||
|
||||
all.size
|
||||
end
|
||||
|
||||
def income_total(currency = "USD")
|
||||
account_transactions.includes(:entryable)
|
||||
.where("account_entries.amount <= 0")
|
||||
.where("account_entries.currency = ?", currency)
|
||||
.reject { |e| e.marked_as_transfer? }
|
||||
.sum(&:amount_money)
|
||||
end
|
||||
|
||||
def expense_total(currency = "USD")
|
||||
account_transactions.includes(:entryable)
|
||||
.where("account_entries.amount > 0")
|
||||
.where("account_entries.currency = ?", currency)
|
||||
.reject { |e| e.marked_as_transfer? }
|
||||
.sum(&:amount_money)
|
||||
end
|
||||
|
||||
def search(params)
|
||||
query = all
|
||||
query = query.where("account_entries.name ILIKE ?", "%#{params[:search]}%") if params[:search].present?
|
||||
query = query.where("account_entries.date >= ?", params[:start_date]) if params[:start_date].present?
|
||||
query = query.where("account_entries.date <= ?", params[:end_date]) if params[:end_date].present?
|
||||
|
||||
if params[:accounts].present? || params[:account_ids].present?
|
||||
query = query.joins(:account)
|
||||
end
|
||||
|
||||
query = query.where(accounts: { name: params[:accounts] }) if params[:accounts].present?
|
||||
query = query.where(accounts: { id: params[:account_ids] }) if params[:account_ids].present?
|
||||
|
||||
# Search attributes on each entryable to further refine results
|
||||
entryable_ids = entryable_search(params)
|
||||
query = query.where(entryable_id: entryable_ids) unless entryable_ids.nil?
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def entryable_search(params)
|
||||
entryable_ids = []
|
||||
entryable_search_performed = false
|
||||
|
||||
Account::Entryable::TYPES.map(&:constantize).each do |entryable|
|
||||
next unless entryable.requires_search?(params)
|
||||
|
||||
entryable_search_performed = true
|
||||
entryable_ids += entryable.search(params).pluck(:id)
|
||||
end
|
||||
|
||||
return nil unless entryable_search_performed
|
||||
|
||||
entryable_ids
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def previous_entry
|
||||
@previous_entry ||= account
|
||||
.entries
|
||||
.where("date < ?", date)
|
||||
.where("entryable_type = ?", entryable_type)
|
||||
.order(date: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
def create_trend
|
||||
TimeSeries::Trend.new \
|
||||
current: amount_money,
|
||||
previous: previous_entry&.amount_money,
|
||||
favorable_direction: account.favorable_direction
|
||||
end
|
||||
end
|
||||
13
app/models/account/entryable.rb
Normal file
13
app/models/account/entryable.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Account::Entryable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
TYPES = %w[ Account::Valuation Account::Transaction ]
|
||||
|
||||
def self.from_type(entryable_type)
|
||||
entryable_type.presence_in(TYPES).constantize
|
||||
end
|
||||
|
||||
included do
|
||||
has_one :entry, as: :entryable, touch: true
|
||||
end
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::Loan < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::OtherAsset < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::OtherLiability < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::Property < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -8,17 +8,22 @@ module Account::Syncable
|
||||
def sync(start_date = nil)
|
||||
update!(status: "syncing")
|
||||
|
||||
sync_exchange_rates
|
||||
if multi_currency? || foreign_currency?
|
||||
sync_exchange_rates
|
||||
end
|
||||
|
||||
calc_start_date = start_date - 1.day if start_date.present? && self.balance_on(start_date - 1.day).present?
|
||||
calculator = Account::Balance::Calculator.new(self, { calc_start_date: start_date })
|
||||
|
||||
calculator = Account::Balance::Calculator.new(self, { calc_start_date: })
|
||||
calculator.calculate
|
||||
self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique)
|
||||
self.balances.where("date < ?", effective_start_date).delete_all
|
||||
new_balance = calculator.daily_balances.select { |b| b[:currency] == self.currency }.last[:balance]
|
||||
|
||||
update!(status: "ok", last_sync_date: Date.today, balance: new_balance, sync_errors: calculator.errors, sync_warnings: calculator.warnings)
|
||||
update! \
|
||||
status: "ok",
|
||||
last_sync_date: Date.current,
|
||||
balance: new_balance,
|
||||
sync_errors: calculator.errors,
|
||||
sync_warnings: calculator.warnings
|
||||
rescue => e
|
||||
update!(status: "error", sync_errors: [ :sync_message_unknown_error ])
|
||||
logger.error("Failed to sync account #{id}: #{e.message}")
|
||||
@@ -37,10 +42,7 @@ module Account::Syncable
|
||||
|
||||
# The earliest date we can calculate a balance for
|
||||
def effective_start_date
|
||||
first_valuation_date = self.valuations.order(:date).pluck(:date).first
|
||||
first_transaction_date = self.transactions.order(:date).pluck(:date).first
|
||||
|
||||
[ first_valuation_date, first_transaction_date&.prev_day ].compact.min || Date.current
|
||||
@effective_start_date ||= entries.order(:date).first.try(:date) || Date.current
|
||||
end
|
||||
|
||||
# Finds all the rate pairs that are required to calculate balances for an account and syncs them
|
||||
@@ -48,7 +50,7 @@ module Account::Syncable
|
||||
rate_candidates = []
|
||||
|
||||
if multi_currency?
|
||||
transactions_in_foreign_currency = self.transactions.where.not(currency: self.currency).pluck(:currency, :date).uniq
|
||||
transactions_in_foreign_currency = self.entries.where.not(currency: self.currency).pluck(:currency, :date).uniq
|
||||
transactions_in_foreign_currency.each do |currency, date|
|
||||
rate_candidates << { date: date, from_currency: currency, to_currency: self.currency }
|
||||
end
|
||||
@@ -60,6 +62,8 @@ module Account::Syncable
|
||||
end
|
||||
end
|
||||
|
||||
return if rate_candidates.blank?
|
||||
|
||||
existing_rates = ExchangeRate.where(
|
||||
base_currency: rate_candidates.map { |rc| rc[:from_currency] },
|
||||
converted_currency: rate_candidates.map { |rc| rc[:to_currency] },
|
||||
|
||||
40
app/models/account/transaction.rb
Normal file
40
app/models/account/transaction.rb
Normal file
@@ -0,0 +1,40 @@
|
||||
class Account::Transaction < ApplicationRecord
|
||||
include Account::Entryable
|
||||
|
||||
belongs_to :category, optional: true
|
||||
belongs_to :merchant, optional: true
|
||||
has_many :taggings, as: :taggable, dependent: :destroy
|
||||
has_many :tags, through: :taggings
|
||||
|
||||
accepts_nested_attributes_for :taggings, allow_destroy: true
|
||||
|
||||
scope :active, -> { where(excluded: false) }
|
||||
|
||||
class << self
|
||||
def search(params)
|
||||
query = all
|
||||
query = query.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id").where(categories: { name: params[:categories] }) if params[:categories].present?
|
||||
query = query.joins("LEFT JOIN merchants ON merchants.id = account_transactions.merchant_id").where(merchants: { name: params[:merchants] }) if params[:merchants].present?
|
||||
query
|
||||
end
|
||||
|
||||
def requires_search?(params)
|
||||
searchable_keys.any? { |key| params.key?(key) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def searchable_keys
|
||||
%i[ categories merchants ]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def previous_transaction_date
|
||||
self.account
|
||||
.transactions
|
||||
.where("date < ?", date)
|
||||
.order(date: :desc)
|
||||
.first&.date
|
||||
end
|
||||
end
|
||||
96
app/models/account/transfer.rb
Normal file
96
app/models/account/transfer.rb
Normal file
@@ -0,0 +1,96 @@
|
||||
class Account::Transfer < ApplicationRecord
|
||||
has_many :entries, dependent: :nullify
|
||||
|
||||
validate :net_zero_flows, if: :single_currency_transfer?
|
||||
validate :transaction_count, :from_different_accounts, :all_transactions_marked
|
||||
|
||||
def date
|
||||
outflow_transaction&.date
|
||||
end
|
||||
|
||||
def amount_money
|
||||
entries.first&.amount_money&.abs
|
||||
end
|
||||
|
||||
def from_name
|
||||
outflow_transaction&.account&.name
|
||||
end
|
||||
|
||||
def to_name
|
||||
inflow_transaction&.account&.name
|
||||
end
|
||||
|
||||
def name
|
||||
return nil unless from_name && to_name
|
||||
I18n.t("account.transfer.name", from_account: from_name, to_account: to_name)
|
||||
end
|
||||
|
||||
def inflow_transaction
|
||||
entries.find { |e| e.inflow? }
|
||||
end
|
||||
|
||||
def outflow_transaction
|
||||
entries.find { |e| e.outflow? }
|
||||
end
|
||||
|
||||
def destroy_and_remove_marks!
|
||||
transaction do
|
||||
entries.each do |e|
|
||||
e.update! marked_as_transfer: false
|
||||
end
|
||||
|
||||
destroy!
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def build_from_accounts(from_account, to_account, date:, amount:, currency:, name:)
|
||||
outflow = from_account.entries.build \
|
||||
amount: amount.abs,
|
||||
currency: currency,
|
||||
date: date,
|
||||
name: name,
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
inflow = to_account.entries.build \
|
||||
amount: amount.abs * -1,
|
||||
currency: currency,
|
||||
date: date,
|
||||
name: name,
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
new entries: [ outflow, inflow ]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def single_currency_transfer?
|
||||
entries.map { |e| e.currency }.uniq.size == 1
|
||||
end
|
||||
|
||||
def transaction_count
|
||||
unless entries.size == 2
|
||||
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
|
||||
end
|
||||
|
||||
def net_zero_flows
|
||||
unless entries.sum(&:amount).zero?
|
||||
errors.add :transactions, "must have an inflow and outflow that net to zero"
|
||||
end
|
||||
end
|
||||
|
||||
def all_transactions_marked
|
||||
unless entries.all?(&:marked_as_transfer)
|
||||
errors.add :entries, "must be marked as transfer"
|
||||
end
|
||||
end
|
||||
end
|
||||
13
app/models/account/valuation.rb
Normal file
13
app/models/account/valuation.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class Account::Valuation < ApplicationRecord
|
||||
include Account::Entryable
|
||||
|
||||
class << self
|
||||
def search(_params)
|
||||
all
|
||||
end
|
||||
|
||||
def requires_search?(_params)
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::Vehicle < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class Transaction::Category < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify
|
||||
class Category < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
||||
belongs_to :family
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
@@ -24,7 +24,7 @@ class Transaction::Category < ApplicationRecord
|
||||
]
|
||||
|
||||
def self.create_default_categories(family)
|
||||
if family.transaction_categories.size > 0
|
||||
if family.categories.size > 0
|
||||
raise ArgumentError, "Family already has some categories"
|
||||
end
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
module Accountable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
ASSET_TYPES = %w[ Account::Depository Account::Investment Account::Crypto Account::OtherAsset Account::Property Account::Vehicle ]
|
||||
LIABILITY_TYPES = %w[ Account::Credit Account::Loan Account::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)
|
||||
return nil unless types.include?(type) || TYPES.include?(type)
|
||||
"Account::#{type.demodulize}".constantize
|
||||
return nil unless TYPES.include?(type)
|
||||
type.constantize
|
||||
end
|
||||
|
||||
def self.by_classification
|
||||
{ assets: ASSET_TYPES, liabilities: LIABILITY_TYPES }
|
||||
end
|
||||
|
||||
def self.types(classification = nil)
|
||||
types = classification ? (classification.to_sym == :asset ? ASSET_TYPES : LIABILITY_TYPES) : TYPES
|
||||
types.map { |type| type.demodulize }
|
||||
end
|
||||
|
||||
def self.classification(type)
|
||||
ASSET_TYPES.include?(type) ? :asset : :liability
|
||||
end
|
||||
|
||||
included do
|
||||
has_one :account, as: :accountable, touch: true
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@ module Monetizable
|
||||
fields.each do |field|
|
||||
define_method("#{field}_money") do
|
||||
value = self.send(field)
|
||||
value.nil? ? nil : Money.new(value, currency)
|
||||
value.nil? ? nil : Money.new(value, currency || Money.default_currency)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
3
app/models/credit_card.rb
Normal file
3
app/models/credit_card.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class CreditCard < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
3
app/models/crypto.rb
Normal file
3
app/models/crypto.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Crypto < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
3
app/models/depository.rb
Normal file
3
app/models/depository.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Depository < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -2,23 +2,25 @@ class Family < ApplicationRecord
|
||||
has_many :users, dependent: :destroy
|
||||
has_many :tags, dependent: :destroy
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :institutions, dependent: :destroy
|
||||
has_many :transactions, through: :accounts
|
||||
has_many :entries, through: :accounts
|
||||
has_many :imports, through: :accounts
|
||||
has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category"
|
||||
has_many :transaction_merchants, dependent: :destroy, class_name: "Transaction::Merchant"
|
||||
has_many :categories, dependent: :destroy
|
||||
has_many :merchants, dependent: :destroy
|
||||
|
||||
def snapshot(period = Period.all)
|
||||
query = accounts.active.joins(:balances)
|
||||
.where("account_balances.currency = ?", self.currency)
|
||||
.select(
|
||||
"account_balances.currency",
|
||||
"account_balances.date",
|
||||
"SUM(CASE WHEN accounts.classification = 'liability' THEN account_balances.balance ELSE 0 END) AS liabilities",
|
||||
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance ELSE 0 END) AS assets",
|
||||
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance WHEN accounts.classification = 'liability' THEN -account_balances.balance ELSE 0 END) AS net_worth",
|
||||
)
|
||||
.group("account_balances.date, account_balances.currency")
|
||||
.order("account_balances.date")
|
||||
.where("account_balances.currency = ?", self.currency)
|
||||
.select(
|
||||
"account_balances.currency",
|
||||
"account_balances.date",
|
||||
"SUM(CASE WHEN accounts.classification = 'liability' THEN account_balances.balance ELSE 0 END) AS liabilities",
|
||||
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance ELSE 0 END) AS assets",
|
||||
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance WHEN accounts.classification = 'liability' THEN -account_balances.balance ELSE 0 END) AS net_worth",
|
||||
)
|
||||
.group("account_balances.date, account_balances.currency")
|
||||
.order("account_balances.date")
|
||||
|
||||
query = query.where("account_balances.date >= ?", period.date_range.begin) if period.date_range.begin
|
||||
query = query.where("account_balances.date <= ?", period.date_range.end) if period.date_range.end
|
||||
@@ -33,16 +35,18 @@ class Family < ApplicationRecord
|
||||
|
||||
def snapshot_account_transactions
|
||||
period = Period.last_30_days
|
||||
results = accounts.active.joins(:transactions)
|
||||
.select(
|
||||
"accounts.*",
|
||||
"COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-amount) FILTER (WHERE amount < 0), 0) AS income"
|
||||
)
|
||||
.where("transactions.date >= ?", period.date_range.begin)
|
||||
.where("transactions.date <= ?", period.date_range.end)
|
||||
.group("id")
|
||||
.to_a
|
||||
results = accounts.active.joins(:entries)
|
||||
.select(
|
||||
"accounts.*",
|
||||
"COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-account_entries.amount) FILTER (WHERE account_entries.amount < 0), 0) AS income"
|
||||
)
|
||||
.where("account_entries.date >= ?", period.date_range.begin)
|
||||
.where("account_entries.date <= ?", period.date_range.end)
|
||||
.where("account_entries.marked_as_transfer = ?", false)
|
||||
.where("account_entries.entryable_type = ?", "Account::Transaction")
|
||||
.group("id")
|
||||
.to_a
|
||||
|
||||
results.each do |r|
|
||||
r.define_singleton_method(:savings_rate) do
|
||||
@@ -58,7 +62,8 @@ class Family < ApplicationRecord
|
||||
end
|
||||
|
||||
def snapshot_transactions
|
||||
rolling_totals = Transaction.daily_rolling_totals(transactions, period: Period.last_30_days, currency: self.currency)
|
||||
candidate_entries = entries.account_transactions.without_transfers
|
||||
rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days)
|
||||
|
||||
spending = []
|
||||
income = []
|
||||
@@ -87,10 +92,6 @@ class Family < ApplicationRecord
|
||||
}
|
||||
end
|
||||
|
||||
def effective_start_date
|
||||
accounts.active.joins(:balances).minimum("account_balances.date") || Date.current
|
||||
end
|
||||
|
||||
def net_worth
|
||||
assets - liabilities
|
||||
end
|
||||
|
||||
@@ -38,7 +38,7 @@ class Import < ApplicationRecord
|
||||
end
|
||||
|
||||
def get_selected_header_for_field(field)
|
||||
column_mappings&.dig(field) || field.key
|
||||
column_mappings&.dig(field.key) || field.key
|
||||
end
|
||||
|
||||
def update_csv!(row_idx:, col_idx:, value:)
|
||||
@@ -111,7 +111,7 @@ class Import < ApplicationRecord
|
||||
end
|
||||
|
||||
def generate_transactions
|
||||
transactions = []
|
||||
transaction_entries = []
|
||||
category_cache = {}
|
||||
tag_cache = {}
|
||||
|
||||
@@ -124,20 +124,19 @@ class Import < ApplicationRecord
|
||||
tags << tag_cache[tag_string] ||= account.family.tags.find_or_initialize_by(name: tag_string)
|
||||
end
|
||||
|
||||
category = category_cache[category_name] ||= account.family.transaction_categories.find_or_initialize_by(name: category_name) if category_name.present?
|
||||
category = category_cache[category_name] ||= account.family.categories.find_or_initialize_by(name: category_name) if category_name.present?
|
||||
|
||||
txn = account.transactions.build \
|
||||
entry = account.entries.build \
|
||||
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
|
||||
date: Date.iso8601(row["date"]),
|
||||
category: category,
|
||||
tags: tags,
|
||||
amount: BigDecimal(row["amount"]) * -1, # User inputs amounts with opposite signage of our internal representation
|
||||
currency: account.currency
|
||||
currency: account.currency,
|
||||
amount: BigDecimal(row["amount"]) * -1,
|
||||
entryable: Account::Transaction.new(category: category, tags: tags)
|
||||
|
||||
transactions << txn
|
||||
transaction_entries << entry
|
||||
end
|
||||
|
||||
transactions
|
||||
transaction_entries
|
||||
end
|
||||
|
||||
def create_expected_fields
|
||||
@@ -148,15 +147,18 @@ class Import < ApplicationRecord
|
||||
|
||||
name_field = Import::Field.new \
|
||||
key: "name",
|
||||
label: "Name"
|
||||
label: "Name",
|
||||
is_optional: true
|
||||
|
||||
category_field = Import::Field.new \
|
||||
key: "category",
|
||||
label: "Category"
|
||||
label: "Category",
|
||||
is_optional: true
|
||||
|
||||
tags_field = Import::Field.new \
|
||||
key: "tags",
|
||||
label: "Tags"
|
||||
label: "Tags",
|
||||
is_optional: true
|
||||
|
||||
amount_field = Import::Field.new \
|
||||
key: "amount",
|
||||
|
||||
@@ -15,12 +15,17 @@ class Import::Field
|
||||
|
||||
attr_reader :key, :label, :validator
|
||||
|
||||
def initialize(key:, label:, validator: nil)
|
||||
def initialize(key:, label:, is_optional: false, validator: nil)
|
||||
@key = key.to_s
|
||||
@label = label
|
||||
@is_optional = is_optional
|
||||
@validator = validator
|
||||
end
|
||||
|
||||
def optional?
|
||||
@is_optional
|
||||
end
|
||||
|
||||
def define_validator(validator = nil, &block)
|
||||
@validator = validator || block
|
||||
end
|
||||
|
||||
7
app/models/institution.rb
Normal file
7
app/models/institution.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class Institution < ApplicationRecord
|
||||
belongs_to :family
|
||||
has_many :accounts, dependent: :nullify
|
||||
has_one_attached :logo
|
||||
|
||||
scope :alphabetically, -> { order(name: :asc) }
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Account::Investment < ApplicationRecord
|
||||
class Investment < ApplicationRecord
|
||||
include Accountable
|
||||
|
||||
SUBTYPES = [
|
||||
3
app/models/loan.rb
Normal file
3
app/models/loan.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Loan < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class Transaction::Merchant < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify
|
||||
class Merchant < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
||||
belongs_to :family
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
3
app/models/other_asset.rb
Normal file
3
app/models/other_asset.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class OtherAsset < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
3
app/models/other_liability.rb
Normal file
3
app/models/other_liability.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class OtherLiability < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
3
app/models/property.rb
Normal file
3
app/models/property.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Property < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -40,6 +40,26 @@ class Provider::Github
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_latest_releases_notes
|
||||
begin
|
||||
Rails.cache.fetch("latest_github_releases_notes", expires_in: 2.hours) do
|
||||
releases = Octokit.releases(repo)
|
||||
releases.map do |release|
|
||||
{
|
||||
avatar: release.author.avatar_url,
|
||||
name: release.name,
|
||||
published_at: release.published_at,
|
||||
body: Octokit.markdown(release.body, mode: "gfm", context: repo)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to fetch latest GitHub releases notes: #{e.message}"
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def repo
|
||||
"#{owner}/#{name}"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Tag < ApplicationRecord
|
||||
belongs_to :family
|
||||
has_many :taggings, dependent: :destroy
|
||||
has_many :transactions, through: :taggings, source: :taggable, source_type: "Transaction"
|
||||
has_many :transactions, through: :taggings, source: :taggable, source_type: "Account::Transaction"
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :family }
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
class TimeSeries::Trend
|
||||
include ActiveModel::Validations
|
||||
|
||||
attr_reader :current, :previous
|
||||
|
||||
delegate :favorable_direction, to: :series
|
||||
attr_reader :current, :previous, :favorable_direction
|
||||
|
||||
validate :values_must_be_of_same_type, :values_must_be_of_known_type
|
||||
|
||||
def initialize(current:, previous:, series: nil)
|
||||
def initialize(current:, previous:, series: nil, favorable_direction: nil)
|
||||
@current = current
|
||||
@previous = previous
|
||||
@series = series
|
||||
@favorable_direction = get_favorable_direction(favorable_direction)
|
||||
|
||||
validate!
|
||||
end
|
||||
@@ -25,6 +24,17 @@ class TimeSeries::Trend
|
||||
end.inquiry
|
||||
end
|
||||
|
||||
def color
|
||||
case direction
|
||||
when "up"
|
||||
favorable_direction.down? ? red_hex : green_hex
|
||||
when "down"
|
||||
favorable_direction.down? ? green_hex : red_hex
|
||||
else
|
||||
gray_hex
|
||||
end
|
||||
end
|
||||
|
||||
def value
|
||||
if previous.nil?
|
||||
current.is_a?(Money) ? Money.new(0) : 0
|
||||
@@ -56,8 +66,21 @@ class TimeSeries::Trend
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :series
|
||||
|
||||
def red_hex
|
||||
"#F13636" # red-500
|
||||
end
|
||||
|
||||
def green_hex
|
||||
"#10A861" # green-600
|
||||
end
|
||||
|
||||
def gray_hex
|
||||
"#737373" # gray-500
|
||||
end
|
||||
|
||||
def values_must_be_of_same_type
|
||||
unless current.class == previous.class || [ previous, current ].any?(&:nil?)
|
||||
errors.add :current, "must be of the same type as previous"
|
||||
@@ -90,4 +113,9 @@ class TimeSeries::Trend
|
||||
obj
|
||||
end
|
||||
end
|
||||
|
||||
def get_favorable_direction(favorable_direction)
|
||||
direction = favorable_direction.presence || series&.favorable_direction
|
||||
(direction.presence_in(TimeSeries::DIRECTIONS) || "up").inquiry
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
class Transaction < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
monetize :amount
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :category, optional: true
|
||||
belongs_to :merchant, optional: true
|
||||
has_many :taggings, as: :taggable, dependent: :destroy
|
||||
has_many :tags, through: :taggings
|
||||
accepts_nested_attributes_for :taggings, allow_destroy: true
|
||||
|
||||
validates :name, :date, :amount, :account, presence: true
|
||||
|
||||
scope :ordered, -> { order(date: :desc) }
|
||||
scope :active, -> { where(excluded: false) }
|
||||
scope :inflows, -> { where("amount <= 0") }
|
||||
scope :outflows, -> { where("amount > 0") }
|
||||
scope :by_name, ->(name) { where("transactions.name ILIKE ?", "%#{name}%") }
|
||||
scope :with_categories, ->(categories) { joins(:category).where(transaction_categories: { name: categories }) }
|
||||
scope :with_accounts, ->(accounts) { joins(:account).where(accounts: { name: accounts }) }
|
||||
scope :with_account_ids, ->(account_ids) { joins(:account).where(accounts: { id: account_ids }) }
|
||||
scope :with_merchants, ->(merchants) { joins(:merchant).where(transaction_merchants: { name: merchants }) }
|
||||
scope :on_or_after_date, ->(date) { where("transactions.date >= ?", date) }
|
||||
scope :on_or_before_date, ->(date) { where("transactions.date <= ?", date) }
|
||||
scope :with_converted_amount, ->(currency = Current.family.currency) {
|
||||
# Join with exchange rates to convert the amount to the given currency
|
||||
# If no rate is available, exclude the transaction from the results
|
||||
select(
|
||||
"transactions.*",
|
||||
"transactions.amount * COALESCE(er.rate, 1) AS converted_amount"
|
||||
)
|
||||
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON transactions.date = er.date AND transactions.currency = er.base_currency AND er.converted_currency = ?", currency ]))
|
||||
.where("er.rate IS NOT NULL OR transactions.currency = ?", currency)
|
||||
}
|
||||
|
||||
def inflow?
|
||||
amount <= 0
|
||||
end
|
||||
|
||||
def outflow?
|
||||
amount > 0
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
if destroyed?
|
||||
sync_start_date = previous_transaction_date
|
||||
else
|
||||
sync_start_date = [ date_previously_was, date ].compact.min
|
||||
end
|
||||
|
||||
account.sync_later(sync_start_date)
|
||||
end
|
||||
|
||||
class << self
|
||||
def daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||
# Sum spending and income for each day in the period with the given currency
|
||||
select(
|
||||
"gs.date",
|
||||
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
|
||||
)
|
||||
.from(transactions.with_converted_amount(currency), :t)
|
||||
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ]))
|
||||
.group("gs.date")
|
||||
end
|
||||
|
||||
def daily_rolling_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||
# Extend the period to include the rolling window
|
||||
period_with_rolling = period.extend_backward(period.date_range.count.days)
|
||||
|
||||
# Aggregate the rolling sum of spending and income based on daily totals
|
||||
rolling_totals = from(daily_totals(transactions, period: period_with_rolling, currency: currency))
|
||||
.select(
|
||||
"*",
|
||||
sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
|
||||
sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
|
||||
)
|
||||
.order("date")
|
||||
|
||||
# Trim the results to the original period
|
||||
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
|
||||
end
|
||||
|
||||
def search(params)
|
||||
query = all
|
||||
query = query.by_name(params[:search]) if params[:search].present?
|
||||
query = query.with_categories(params[:categories]) if params[:categories].present?
|
||||
query = query.with_accounts(params[:accounts]) if params[:accounts].present?
|
||||
query = query.with_account_ids(params[:account_ids]) if params[:account_ids].present?
|
||||
query = query.with_merchants(params[:merchants]) if params[:merchants].present?
|
||||
query = query.on_or_after_date(params[:start_date]) if params[:start_date].present?
|
||||
query = query.on_or_before_date(params[:end_date]) if params[:end_date].present?
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def previous_transaction_date
|
||||
self.account
|
||||
.transactions
|
||||
.where("date < ?", date)
|
||||
.order(date: :desc)
|
||||
.first&.date
|
||||
end
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
class Valuation < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
belongs_to :account
|
||||
validates :account, :date, :value, presence: true
|
||||
monetize :value
|
||||
|
||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||
|
||||
def self.to_series
|
||||
TimeSeries.from_collection all, :value_money
|
||||
end
|
||||
end
|
||||
3
app/models/vehicle.rb
Normal file
3
app/models/vehicle.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Vehicle < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
4
app/views/account/entries/_entry.html.erb
Normal file
4
app/views/account/entries/_entry.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<%# locals: (entry:, **opts) %>
|
||||
<%= turbo_frame_tag dom_id(entry) do %>
|
||||
<%= render permitted_entryable_partial_path(entry, entry.entryable_name_short), entry: entry, **opts %>
|
||||
<% end %>
|
||||
21
app/views/account/entries/_entry_group.html.erb
Normal file
21
app/views/account/entries/_entry_group.html.erb
Normal file
@@ -0,0 +1,21 @@
|
||||
<%# locals: (date:, entries:, selectable: true, **opts) %>
|
||||
<div id="entry-group-<%= date %>" class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
|
||||
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<div class="flex pl-0.5 items-center gap-4">
|
||||
<% if selectable %>
|
||||
<%= check_box_tag "#{date}_entries_selection",
|
||||
class: ["maybe-checkbox maybe-checkbox--light", "hidden": entries.size == 0],
|
||||
id: "selection_entry_#{date}",
|
||||
data: { action: "bulk-select#toggleGroupSelection" } %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.span "#{date.strftime('%b %d, %Y')} · #{entries.size}" %>
|
||||
</div>
|
||||
|
||||
<%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %>
|
||||
</div>
|
||||
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
|
||||
<%= render entries.reject { |e| e.transfer_id.present? }, selectable:, **opts %>
|
||||
<%= render transfer_entries(entries), selectable:, **opts %>
|
||||
</div>
|
||||
</div>
|
||||
5
app/views/account/entries/_loading.html.erb
Normal file
5
app/views/account/entries/_loading.html.erb
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||
<div class="p-5 flex justify-center items-center">
|
||||
<%= tag.p t(".loading"), class: "text-gray-500 animate-pulse text-sm" %>
|
||||
</div>
|
||||
</div>
|
||||
1
app/views/account/entries/_ruler.html.erb
Normal file
1
app/views/account/entries/_ruler.html.erb
Normal file
@@ -0,0 +1 @@
|
||||
<div class="h-px bg-alpha-black-50 ml-16 mr-4"></div>
|
||||
45
app/views/account/entries/_selection_bar.html.erb
Normal file
45
app/views/account/entries/_selection_bar.html.erb
Normal file
@@ -0,0 +1,45 @@
|
||||
<div class="fixed bottom-6 z-10 flex items-center justify-between rounded-xl bg-gray-900 px-4 text-sm text-white w-[420px] py-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= check_box_tag "entry_selection", 1, true, class: "maybe-checkbox maybe-checkbox--dark", data: { action: "bulk-select#deselectAll" } %>
|
||||
|
||||
<p data-bulk-select-target="selectionBarText"></p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 text-gray-500">
|
||||
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
|
||||
|
||||
<%= form_with url: mark_transfers_transactions_path,
|
||||
builder: ActionView::Helpers::FormBuilder,
|
||||
scope: "bulk_update",
|
||||
data: {
|
||||
turbo_frame: "_top",
|
||||
turbo_confirm: {
|
||||
title: t(".mark_transfers"),
|
||||
body: t(".mark_transfers_message"),
|
||||
accept: t(".mark_transfers_confirm"),
|
||||
}
|
||||
} do |f| %>
|
||||
<button id="bulk-transfer-btn"
|
||||
type="button"
|
||||
data-bulk-select-scope-param="bulk_update"
|
||||
data-action="bulk-select#submitBulkRequest"
|
||||
class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md"
|
||||
title="Mark as transfer">
|
||||
<%= lucide_icon "arrow-right-left", class: "w-5 group-hover:text-white" %>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= link_to bulk_edit_transactions_path,
|
||||
class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md",
|
||||
title: "Edit",
|
||||
data: { turbo_frame: "bulk_transaction_edit_drawer" } do %>
|
||||
<%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %>
|
||||
<% end %>
|
||||
|
||||
<%= form_with url: bulk_delete_transactions_path, builder: ActionView::Helpers::FormBuilder, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
|
||||
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
|
||||
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
3
app/views/account/entries/edit.html.erb
Normal file
3
app/views/account/entries/edit.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<%= turbo_frame_tag dom_id(@entry) do %>
|
||||
<%= render permitted_entryable_partial_path(@entry, "edit"), entry: @entry %>
|
||||
<% end %>
|
||||
123
app/views/account/entries/entryables/transaction/_show.html.erb
Normal file
123
app/views/account/entries/entryables/transaction/_show.html.erb
Normal file
@@ -0,0 +1,123 @@
|
||||
<%# locals: (entry:) %>
|
||||
|
||||
<% transaction, account = entry.account_transaction, entry.account %>
|
||||
|
||||
<%= drawer do %>
|
||||
<div>
|
||||
<header class="mb-4 space-y-1">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium">
|
||||
<span class="text-2xl"><%= format_money -entry.amount_money %></span>
|
||||
<span class="text-lg text-gray-500"><%= entry.currency %></span>
|
||||
</h3>
|
||||
|
||||
<% if entry.marked_as_transfer? %>
|
||||
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<span class="text-sm text-gray-500"><%= entry.date.strftime("%A %d %B") %></span>
|
||||
</header>
|
||||
|
||||
<div class="space-y-2">
|
||||
<details class="group space-y-2" open>
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
||||
<h4><%= t(".overview") %></h4>
|
||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||
</summary>
|
||||
|
||||
<div class="pb-6">
|
||||
<%= form_with model: [account, entry], url: account_entry_path(account, entry), html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<div class="space-y-2">
|
||||
<%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %>
|
||||
<% unless entry.marked_as_transfer? %>
|
||||
<div class="flex space-x-2">
|
||||
<div>
|
||||
<%= f.select :nature, [["Expense", "expense"], ["Income", "income"]], { label: t(".nature"), selected: entry.amount.negative? ? "income" : "expense" }, "data-auto-submit-form-target": "auto" %>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<%= f.number_field :amount, value: entry.amount.abs, label: t(".amount"), step: "0.01", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "change" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= f.date_field :date, label: t(".date_label"), max: Date.current, "data-auto-submit-form-target": "auto" %>
|
||||
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<% unless entry.marked_as_transfer? %>
|
||||
<%= ef.collection_select :category_id, selectable_categories, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
||||
<%= ef.collection_select :merchant_id, selectable_merchants, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= f.collection_select :account_id, selectable_accounts, :id, :name, { prompt: t(".account_placeholder"), label: t(".account_label"), class: "text-gray-500" }, { class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group space-y-2" open>
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
||||
<h4><%= t(".additional") %></h4>
|
||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||
</summary>
|
||||
|
||||
<div class="pb-6 space-y-2">
|
||||
<%= form_with model: [account, entry], url: account_entry_path(account, entry), html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<%= ef.select :tag_ids,
|
||||
options_for_select(selectable_tags, transaction.tag_ids),
|
||||
{
|
||||
multiple: true,
|
||||
label: t(".tags_label"),
|
||||
class: "placeholder:text-gray-500"
|
||||
},
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<%= ef.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), "data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group space-y-2" open>
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
||||
<h4><%= t(".settings") %></h4>
|
||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||
</summary>
|
||||
|
||||
<div class="pb-6">
|
||||
<%= form_with model: [account, entry], url: account_entry_path(account, entry), html: { class: "p-3 space-y-3", data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<div class="flex cursor-pointer items-center gap-2 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
|
||||
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
|
||||
</div>
|
||||
|
||||
<div class="relative inline-block select-none">
|
||||
<%= ef.check_box :excluded, class: "sr-only peer", "data-auto-submit-form-target": "auto" %>
|
||||
<label for="account_entry_entryable_attributes_excluded" class="maybe-switch"></label>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% unless entry.marked_as_transfer? %>
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-gray-900"><%= t(".delete_title") %></h4>
|
||||
<p class="text-gray-500"><%= t(".delete_subtitle") %></p>
|
||||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
account_entry_path(account, entry),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
|
||||
data: { turbo_confirm: true, turbo_frame: "_top" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,90 @@
|
||||
<%# locals: (entry:, selectable: true, editable: true, short: false, show_tags: false, **opts) %>
|
||||
<% transaction, account = entry.account_transaction, entry.account %>
|
||||
|
||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||
<% name_col_span = entry.marked_as_transfer? ? "col-span-10" : short ? "col-span-6" : "col-span-4" %>
|
||||
<div class="pr-10 flex items-center gap-4 <%= name_col_span %>">
|
||||
<% if selectable %>
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
|
||||
<% end %>
|
||||
|
||||
<div class="max-w-full">
|
||||
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
|
||||
<%= entry.name[0].upcase %>
|
||||
</div>
|
||||
|
||||
<div class="truncate text-gray-900">
|
||||
<% if entry.new_record? %>
|
||||
<%= content_tag :p, entry.name %>
|
||||
<% else %>
|
||||
<%= link_to entry.name,
|
||||
account_entry_path(account, entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if unconfirmed_transfer?(entry) %>
|
||||
<% if editable %>
|
||||
<%= form_with url: unmark_transfers_transactions_path, builder: ActionView::Helpers::FormBuilder, class: "flex items-center", data: {
|
||||
turbo_confirm: {
|
||||
title: t(".remove_transfer"),
|
||||
body: t(".remove_transfer_body"),
|
||||
accept: t(".remove_transfer_confirm"),
|
||||
},
|
||||
turbo_frame: "_top"
|
||||
} do |f| %>
|
||||
<%= f.hidden_field "bulk_update[entry_ids][]", value: entry.id %>
|
||||
<%= f.button class: "flex items-center justify-center group", title: "Remove transfer" do %>
|
||||
<%= lucide_icon "arrow-left-right", class: "group-hover:hidden text-gray-500 w-4 h-4" %>
|
||||
<%= lucide_icon "unlink", class: "hidden group-hover:inline-block text-gray-900 w-4 h-4" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= lucide_icon "arrow-left-right", class: "text-gray-500 w-4 h-4" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% unless entry.marked_as_transfer? %>
|
||||
<% unless short %>
|
||||
<div class="flex items-center gap-1 <%= show_tags ? "col-span-6" : "col-span-3" %>">
|
||||
<% if editable %>
|
||||
<%= render "categories/menu", transaction: transaction %>
|
||||
<% else %>
|
||||
<%= render "categories/badge", category: transaction.category %>
|
||||
<% end %>
|
||||
|
||||
<% if show_tags %>
|
||||
<% transaction.tags.each do |tag| %>
|
||||
<%= render partial: "tags/badge", locals: { tag: tag } %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% unless show_tags %>
|
||||
<%= tag.div class: short ? "col-span-4" : "col-span-3" do %>
|
||||
<% if entry.new_record? %>
|
||||
<%= tag.p account.name %>
|
||||
<% else %>
|
||||
<%= link_to account.name,
|
||||
account_path(account, tab: "transactions"),
|
||||
data: { turbo_frame: "_top" },
|
||||
class: "hover:underline" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="col-span-2 ml-auto">
|
||||
<%= content_tag :p,
|
||||
format_money(-entry.amount_money),
|
||||
class: ["text-green-600": entry.inflow?] %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %>
|
||||
@@ -0,0 +1,24 @@
|
||||
<%# locals: (entry:) %>
|
||||
<%= form_with model: [entry.account, entry],
|
||||
data: { turbo_frame: "_top" },
|
||||
url: entry.new_record? ? account_entries_path(entry.account) : account_entry_path(entry.account, entry),
|
||||
builder: ActionView::Helpers::FormBuilder do |f| %>
|
||||
<div class="grid grid-cols-10 p-4 items-center">
|
||||
<div class="col-span-7 flex items-center gap-4">
|
||||
<div class="w-8 h-8 rounded-full p-1.5 flex items-center justify-center bg-gray-500/5">
|
||||
<%= lucide_icon("pencil-line", class: "w-4 h-4 text-gray-500") %>
|
||||
</div>
|
||||
<div class="w-full flex items-center justify-between gap-2">
|
||||
<%= f.date_field :date, required: "required", max: Date.current, class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5 text-gray-900 text-sm" %>
|
||||
<%= f.number_field :amount, required: "required", placeholder: "0.00", step: "0.01", class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs text-gray-900 text-sm px-3 py-1.5 text-right" %>
|
||||
<%= f.hidden_field :currency, value: entry.account.currency %>
|
||||
<%= f.hidden_field :entryable_type, value: entry.entryable_type %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 flex gap-2 justify-end items-center">
|
||||
<%= link_to t(".cancel"), valuation_account_entries_path(entry.account), class: "text-sm text-gray-900 hover:text-gray-800 font-medium px-3 py-1.5" %>
|
||||
<%= f.submit class: "bg-gray-50 rounded-lg font-medium px-3 py-1.5 cursor-pointer hover:bg-gray-100 text-sm" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,2 @@
|
||||
<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %>
|
||||
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>
|
||||
@@ -0,0 +1 @@
|
||||
<%= render permitted_entryable_partial_path(@entry, "valuation"), entry: @entry %>
|
||||
@@ -0,0 +1,50 @@
|
||||
<%# locals: (entry:, **opts) %>
|
||||
|
||||
<% account = entry.account %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(entry) do %>
|
||||
<% is_oldest = entry.first_of_type? %>
|
||||
|
||||
<div class="p-4 grid grid-cols-10 items-center">
|
||||
<div class="col-span-5 flex items-center gap-4">
|
||||
<%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: entry_style(entry, is_oldest:).html_safe do %>
|
||||
<%= lucide_icon entry_icon(entry, is_oldest:), class: "w-4 h-4" %>
|
||||
<% end %>
|
||||
|
||||
<div class="text-sm">
|
||||
<%= tag.p entry.date, class: "text-gray-900 font-medium" %>
|
||||
<%= tag.p is_oldest ? t(".start_balance") : t(".value_update"), class: "text-gray-500" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 justify-self-end">
|
||||
<%= tag.p format_money(entry.amount_money), class: "font-medium text-sm text-gray-900" %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 justify-self-end font-medium text-sm" style="color: <%= entry.trend.color %>">
|
||||
<% if entry.trend.direction.flat? %>
|
||||
<%= tag.span t(".no_change"), class: "text-gray-500" %>
|
||||
<% else %>
|
||||
<%= tag.span format_money(entry.trend.value) %>
|
||||
<%= tag.span "(#{entry.trend.percent}%)" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1 justify-self-end">
|
||||
<%= contextual_menu do %>
|
||||
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<%= contextual_menu_modal_action_item t(".edit_entry"), edit_account_entry_path(account, entry) %>
|
||||
|
||||
<%= contextual_menu_destructive_item t(".delete_entry"),
|
||||
account_entry_path(account, entry),
|
||||
turbo_frame: "_top",
|
||||
turbo_confirm: {
|
||||
title: t(".confirm_title"),
|
||||
body: t(".confirm_body_html"),
|
||||
accept: t(".confirm_accept")
|
||||
} %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
3
app/views/account/entries/new.html.erb
Normal file
3
app/views/account/entries/new.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<%= turbo_frame_tag dom_id(@entry) do %>
|
||||
<%= render permitted_entryable_partial_path(@entry, "new"), entry: @entry %>
|
||||
<% end %>
|
||||
1
app/views/account/entries/show.html.erb
Normal file
1
app/views/account/entries/show.html.erb
Normal file
@@ -0,0 +1 @@
|
||||
<%= render partial: permitted_entryable_partial_path(@entry, "show"), locals: { entry: @entry } %>
|
||||
29
app/views/account/entries/transactions.html.erb
Normal file
29
app/views/account/entries/transactions.html.erb
Normal file
@@ -0,0 +1,29 @@
|
||||
<%= turbo_frame_tag dom_id(@account, "transactions") do %>
|
||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="font-medium text-lg"><%= t(".transactions") %></h3>
|
||||
<%= link_to new_transaction_path(account_id: @account),
|
||||
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg",
|
||||
data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
||||
<span class="text-sm"><%= t(".new") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div id="transactions" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".transaction") %>">
|
||||
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
|
||||
<%= render "selection_bar" %>
|
||||
</div>
|
||||
|
||||
<% if @transaction_entries.empty? %>
|
||||
<p class="text-gray-500 py-4"><%= t(".no_transactions") %></p>
|
||||
<% else %>
|
||||
<div class="space-y-6">
|
||||
<% @transaction_entries.group_by(&:date).each do |date, entries| %>
|
||||
<%= render "entry_group", date:, entries: entries %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
35
app/views/account/entries/valuations.html.erb
Normal file
35
app/views/account/entries/valuations.html.erb
Normal file
@@ -0,0 +1,35 @@
|
||||
<%= turbo_frame_tag dom_id(@account, "valuations") do %>
|
||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<%= tag.h2 t(".valuations"), class: "font-medium text-lg" %>
|
||||
<%= link_to new_account_entry_path(@account, entryable_type: "Account::Valuation"),
|
||||
data: { turbo_frame: dom_id(@account.entries.account_valuations.new) },
|
||||
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
||||
<%= tag.span t(".new_entry"), class: "text-sm" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-25 p-1">
|
||||
<div class="grid grid-cols-10 items-center uppercase text-xs font-medium text-gray-500 px-4 py-2">
|
||||
<%= tag.p t(".date"), class: "col-span-5" %>
|
||||
<%= tag.p t(".value"), class: "col-span-2 justify-self-end" %>
|
||||
<%= tag.p t(".change"), class: "col-span-2 justify-self-end" %>
|
||||
<%= tag.div class: "col-span-1" %>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
||||
<%= turbo_frame_tag dom_id(@account.entries.account_valuations.new) %>
|
||||
|
||||
<% if @valuation_entries.any? %>
|
||||
<%= render partial: "account/entries/entryables/valuation/valuation",
|
||||
collection: @valuation_entries,
|
||||
as: :entry,
|
||||
spacer_template: "ruler" %>
|
||||
<% else %>
|
||||
<p class="text-gray-500 text-sm p-4"><%= t(".no_valuations") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
Before Width: | Height: | Size: 653 B After Width: | Height: | Size: 653 B |
32
app/views/account/transfers/_form.html.erb
Normal file
32
app/views/account/transfers/_form.html.erb
Normal file
@@ -0,0 +1,32 @@
|
||||
<%= form_with model: transfer, data: { turbo_frame: "_top" } do |f| %>
|
||||
<section>
|
||||
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
|
||||
<%= link_to new_transaction_path(nature: "expense"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
|
||||
<%= lucide_icon "minus-circle", class: "w-5 h-5" %>
|
||||
<%= tag.span t(".expense") %>
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_transaction_path(nature: "income"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
|
||||
<%= lucide_icon "plus-circle", class: "w-5 h-5" %>
|
||||
<%= tag.span t(".income") %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.div class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 bg-white text-gray-800 shadow-sm" do %>
|
||||
<%= lucide_icon "arrow-right-left", class: "w-5 h-5" %>
|
||||
<%= tag.span t(".transfer") %>
|
||||
<% end %>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<%= f.text_field :name, value: transfer.name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
|
||||
<%= f.collection_select :from_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %>
|
||||
<%= f.collection_select :to_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %>
|
||||
<%= f.money_field :amount_money, label: t(".amount"), required: true %>
|
||||
<%= f.date_field :date, value: transfer.date, label: t(".date"), required: true, max: Date.current %>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<%= f.submit t(".submit") %>
|
||||
</section>
|
||||
<% end %>
|
||||
49
app/views/account/transfers/_transfer.html.erb
Normal file
49
app/views/account/transfers/_transfer.html.erb
Normal file
@@ -0,0 +1,49 @@
|
||||
<%# locals: (transfer:, selectable: true, editable: true, short: false, **opts) %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(transfer) do %>
|
||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||
<div class="col-span-7 flex items-center">
|
||||
<% if selectable %>
|
||||
<%= check_box_tag dom_id(transfer, "selection"),
|
||||
disabled: true,
|
||||
class: "mr-3 cursor-not-allowed maybe-checkbox maybe-checkbox--light" %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.div class: short ? "max-w-[250px]" : "max-w-[325px]" do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= circle_logo("T") %>
|
||||
|
||||
<%= tag.p transfer.name, class: "truncate text-gray-900" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= button_to account_transfer_path(transfer),
|
||||
method: :delete,
|
||||
class: "ml-2 flex items-center group/transfer hover:bg-gray-50 rounded-md p-1",
|
||||
data: {
|
||||
turbo_frame: "_top",
|
||||
turbo_confirm: {
|
||||
title: t(".remove_title"),
|
||||
body: t(".remove_body"),
|
||||
confirm: t(".remove_confirm")
|
||||
}
|
||||
} do %>
|
||||
|
||||
<%= lucide_icon "link-2", class: "group-hover/transfer:hidden w-4 h-4 text-gray-500" %>
|
||||
<%= lucide_icon "unlink", class: "group-hover/transfer:inline-block hidden w-4 h-4 text-gray-500" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% unless short %>
|
||||
<div class="col-span-3 flex items-center gap-2">
|
||||
<%= circle_logo(transfer.from_name[0].upcase, size: "sm") %>
|
||||
<span class="text-gray-500 font-medium">→</span>
|
||||
<%= circle_logo(transfer.to_name[0].upcase, size: "sm") %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="ml-auto <%= short ? "col-span-5" : "col-span-2" %>">
|
||||
<%= tag.p format_money(transfer.amount_money), class: "font-medium" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
17
app/views/account/transfers/new.html.erb
Normal file
17
app/views/account/transfers/new.html.erb
Normal file
@@ -0,0 +1,17 @@
|
||||
<%= modal do %>
|
||||
<article class="mx-auto p-4 space-y-4 w-screen max-w-xl">
|
||||
<header class="flex justify-between">
|
||||
<%= tag.h2 t(".title"), class: "font-medium text-xl" %>
|
||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||
</header>
|
||||
|
||||
<% if @transfer.errors.present? %>
|
||||
<div class="text-red-600 flex items-center gap-2">
|
||||
<%= lucide_icon "circle-alert", class: "w-5 h-5" %>
|
||||
<p class="text-sm"><%= @transfer.errors.full_messages.to_sentence %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= render "form", transfer: @transfer %>
|
||||
</article>
|
||||
<% end %>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user