Compare commits
108 Commits
v0.2.0
...
missing-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd1a7fa8cb | ||
|
|
f57fa526af | ||
|
|
b02380ac97 | ||
|
|
550991e240 | ||
|
|
4a768d0358 | ||
|
|
a1065fde83 | ||
|
|
f63aea7f87 | ||
|
|
872a480c0f | ||
|
|
1620d56e2d | ||
|
|
c1e48bd3c9 | ||
|
|
4c083fec0a | ||
|
|
2c2b600163 | ||
|
|
53f4b32c33 | ||
|
|
4bf72506d5 | ||
|
|
41873de11d | ||
|
|
46e86a9a11 | ||
|
|
ad5b0b8b7d | ||
|
|
ded42a8c33 | ||
|
|
b78fd1d755 | ||
|
|
0696e1f2f7 | ||
|
|
282c05345d | ||
|
|
0b17976256 | ||
|
|
3b0f8ae8c2 | ||
|
|
247d91b99d | ||
|
|
d428a1f954 | ||
|
|
8256d116dd | ||
|
|
de90b29201 | ||
|
|
0b4e314f58 | ||
|
|
6c8974a086 | ||
|
|
7265f58518 | ||
|
|
2a202576f8 | ||
|
|
d2a7aef6ef | ||
|
|
eabfb7aae1 | ||
|
|
2a1b5fab1a | ||
|
|
5cc592d38f | ||
|
|
8be5bb07c8 | ||
|
|
caf359deed | ||
|
|
91149ceff8 | ||
|
|
f9d4270a75 | ||
|
|
beb6e36577 | ||
|
|
217a96c02d | ||
|
|
e617d791d3 | ||
|
|
7e0ec4bd8f | ||
|
|
3140835f28 | ||
|
|
7d04ea1071 | ||
|
|
43dd16e3fb | ||
|
|
61321f6b16 | ||
|
|
0476f25952 | ||
|
|
e4a374772a | ||
|
|
44961f3628 | ||
|
|
68c570eed8 | ||
|
|
67d81f866f | ||
|
|
72fd177707 | ||
|
|
abccba3947 | ||
|
|
9808641110 | ||
|
|
9fadc6ba63 | ||
|
|
39139ce21a | ||
|
|
51e8fae26d | ||
|
|
42d2197ea1 | ||
|
|
a9c1e85a58 | ||
|
|
8c8e972dc8 | ||
|
|
ae9287ec9b | ||
|
|
aac9e5eca2 | ||
|
|
ca8bdb6241 | ||
|
|
1ae4b4d612 | ||
|
|
60f1a1e2d2 | ||
|
|
e1d3c7a4a1 | ||
|
|
195ec85d96 | ||
|
|
413ec6cbed | ||
|
|
e4e5ae9f25 | ||
|
|
5449fc49ef | ||
|
|
b50b7b30e8 | ||
|
|
871a68b5bc | ||
|
|
1f4c2165eb | ||
|
|
71598d26cb | ||
|
|
997d0355d4 | ||
|
|
2c30e18c9b | ||
|
|
307a3687e8 | ||
|
|
46e129308f | ||
|
|
5d1a2937bb | ||
|
|
b82b82ddf7 | ||
|
|
97852bc3b4 | ||
|
|
84d2aac1a5 | ||
|
|
49d3a9c7e7 | ||
|
|
b7019744a1 | ||
|
|
a9e791f94c | ||
|
|
cce373c31b | ||
|
|
0220861a3b | ||
|
|
fb6b6ce63d | ||
|
|
dba10c2bc8 | ||
|
|
b0d9891133 | ||
|
|
9d217afb9f | ||
|
|
77def1db40 | ||
|
|
a4d10097d5 | ||
|
|
7be6a372bf | ||
|
|
68617514b0 | ||
|
|
ba878c3d8b | ||
|
|
6034dfe5f5 | ||
|
|
ae30176816 | ||
|
|
7508ae55ac | ||
|
|
bb9fa56add | ||
|
|
54e46c1b4e | ||
|
|
0d09f2e3e9 | ||
|
|
f7ce2cdf89 | ||
|
|
f7e86d4c90 | ||
|
|
45add7512b | ||
|
|
9130089950 | ||
|
|
fe199f2357 |
@@ -1,4 +1,4 @@
|
||||
ARG RUBY_VERSION=3.3.5
|
||||
ARG RUBY_VERSION=3.4.1
|
||||
FROM ruby:${RUBY_VERSION}-slim-bullseye
|
||||
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
|
||||
@@ -118,4 +118,6 @@ STRIPE_WEBHOOK_SECRET=
|
||||
#
|
||||
PLAID_CLIENT_ID=
|
||||
PLAID_SECRET=
|
||||
PLAID_ENV=
|
||||
PLAID_ENV=
|
||||
PLAID_EU_CLIENT_ID=
|
||||
PLAID_EU_SECRET=
|
||||
|
||||
18
.github/ISSUE_TEMPLATE/bug_report.md
vendored
18
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -2,11 +2,18 @@
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: 'Bug: '
|
||||
labels: ":bug: Bug"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Where did this bug occur? (required)**
|
||||
|
||||
- [ ] I am a self-hosted user reporting a bug from my self hosted app
|
||||
- [ ] I am a user of Maybe's paid app
|
||||
|
||||
_Please note, if you are reporting a bug with sensitive data, please open an Intercom chat from within the app for help_
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
@@ -20,14 +27,5 @@ Steps to reproduce the behavior:
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**What version of Maybe are you using?**
|
||||
This could be "Hosted" (i.e. app.maybefinance.com) or "Self-hosted". If "Self-hosted", please include the version you're currently on.
|
||||
|
||||
**What operating system and browser are you using?**
|
||||
The more info the better.
|
||||
|
||||
**Screenshots / Recordings**
|
||||
If applicable, add screenshots or short video recordings to help show the bug in more detail.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
platforms: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64,linux/arm/v7' || 'linux/amd64,linux/arm64' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.3.5
|
||||
3.4.1
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
|
||||
ARG RUBY_VERSION=3.3.5
|
||||
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
|
||||
ARG RUBY_VERSION=3.4.1
|
||||
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
|
||||
|
||||
# Rails app lives here
|
||||
WORKDIR /rails
|
||||
@@ -19,7 +19,7 @@ ENV RAILS_ENV="production" \
|
||||
|
||||
|
||||
# Throw-away build stage to reduce size of final image
|
||||
FROM base as build
|
||||
FROM base AS build
|
||||
|
||||
# Install packages needed to build gems
|
||||
RUN apt-get install --no-install-recommends -y build-essential git libpq-dev pkg-config
|
||||
|
||||
4
Gemfile
4
Gemfile
@@ -28,11 +28,12 @@ gem "good_job"
|
||||
|
||||
# Error logging
|
||||
gem "stackprof"
|
||||
gem "rack-mini-profiler"
|
||||
gem "sentry-ruby"
|
||||
gem "sentry-rails"
|
||||
|
||||
# Active Storage
|
||||
gem "aws-sdk-s3", require: false
|
||||
gem "aws-sdk-s3", "~> 1.177.0", require: false
|
||||
gem "image_processing", ">= 1.2"
|
||||
|
||||
# Other
|
||||
@@ -67,6 +68,7 @@ group :development do
|
||||
gem "ruby-lsp-rails"
|
||||
gem "web-console"
|
||||
gem "faker"
|
||||
gem "benchmark-ips"
|
||||
end
|
||||
|
||||
group :test do
|
||||
|
||||
353
Gemfile.lock
353
Gemfile.lock
@@ -8,29 +8,29 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actioncable (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionmailbox (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activestorage (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionmailer (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
actionview (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionpack (7.2.2.1)
|
||||
actionview (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4, < 3.2)
|
||||
@@ -39,35 +39,35 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actiontext (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activestorage (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionview (7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activejob (7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activerecord (7.2.2)
|
||||
activemodel (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activemodel (7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
activerecord (7.2.2.1)
|
||||
activemodel (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activestorage (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.2.2)
|
||||
activesupport (7.2.2.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
@@ -83,24 +83,25 @@ GEM
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1018.0)
|
||||
aws-sdk-core (3.214.0)
|
||||
aws-partitions (1.1043.0)
|
||||
aws-sdk-core (3.217.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.96.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-kms (1.97.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.176.0)
|
||||
aws-sdk-s3 (1.177.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.10.1)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.0)
|
||||
benchmark-ips (2.14.0)
|
||||
better_html (2.1.1)
|
||||
actionview (>= 6.0)
|
||||
activesupport (>= 6.0)
|
||||
@@ -108,11 +109,11 @@ GEM
|
||||
erubi (~> 1.4)
|
||||
parser (>= 2.4)
|
||||
smart_properties
|
||||
bigdecimal (3.1.8)
|
||||
bigdecimal (3.1.9)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (6.2.2)
|
||||
brakeman (7.0.0)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
@@ -124,76 +125,80 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
childprocess (5.0.0)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
climate_control (1.2.0)
|
||||
concurrent-ruby (1.3.4)
|
||||
connection_pool (2.4.1)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.0)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
csv (3.3.0)
|
||||
date (3.4.0)
|
||||
debug (1.9.2)
|
||||
csv (3.3.2)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
docile (1.4.0)
|
||||
dotenv (3.1.4)
|
||||
dotenv-rails (3.1.4)
|
||||
dotenv (= 3.1.4)
|
||||
docile (1.4.1)
|
||||
dotenv (3.1.7)
|
||||
dotenv-rails (3.1.7)
|
||||
dotenv (= 3.1.7)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.1)
|
||||
erb_lint (0.7.0)
|
||||
erb_lint (0.9.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
parser (>= 2.7.1.4)
|
||||
rainbow
|
||||
rubocop (>= 1)
|
||||
smart_properties
|
||||
erubi (1.13.0)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.12.1)
|
||||
faraday (2.12.2)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-multipart (1.1.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-retry (2.2.1)
|
||||
faraday (~> 2.0)
|
||||
ffi (1.17.0-aarch64-linux-gnu)
|
||||
ffi (1.17.0-arm-linux-gnu)
|
||||
ffi (1.17.0-arm64-darwin)
|
||||
ffi (1.17.0-x86-linux-gnu)
|
||||
ffi (1.17.0-x86_64-darwin)
|
||||
ffi (1.17.0-x86_64-linux-gnu)
|
||||
ffi (1.17.1-aarch64-linux-gnu)
|
||||
ffi (1.17.1-aarch64-linux-musl)
|
||||
ffi (1.17.1-arm-linux-gnu)
|
||||
ffi (1.17.1-arm-linux-musl)
|
||||
ffi (1.17.1-arm64-darwin)
|
||||
ffi (1.17.1-x86_64-darwin)
|
||||
ffi (1.17.1-x86_64-linux-gnu)
|
||||
ffi (1.17.1-x86_64-linux-musl)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.5.1)
|
||||
good_job (4.8.2)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (>= 1.11.0)
|
||||
railties (>= 6.1.0)
|
||||
thor (>= 1.0.0)
|
||||
hashdiff (1.1.1)
|
||||
highline (3.0.1)
|
||||
hotwire-livereload (1.4.1)
|
||||
actioncable (>= 6.0.0)
|
||||
hashdiff (1.1.2)
|
||||
highline (3.1.2)
|
||||
reline
|
||||
hotwire-livereload (2.0.0)
|
||||
actioncable (>= 7.0.0)
|
||||
listen (>= 3.0.0)
|
||||
railties (>= 6.0.0)
|
||||
railties (>= 7.0.0)
|
||||
hotwire_combobox (0.3.2)
|
||||
rails (>= 7.0.7.2)
|
||||
stimulus-rails (>= 1.2)
|
||||
turbo-rails (>= 1.2)
|
||||
i18n (1.14.6)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-tasks (1.0.14)
|
||||
activesupport (>= 4.0.2)
|
||||
@@ -208,34 +213,37 @@ GEM
|
||||
image_processing (1.13.0)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
importmap-rails (2.0.3)
|
||||
importmap-rails (2.1.0)
|
||||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
inline_svg (1.10.0)
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
intercom-rails (1.0.1)
|
||||
intercom-rails (1.0.6)
|
||||
activesupport (> 4.0)
|
||||
jwt (~> 2.0)
|
||||
io-console (0.8.0)
|
||||
irb (1.14.1)
|
||||
irb (1.15.1)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.8.2)
|
||||
jwt (2.9.3)
|
||||
json (2.9.1)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
language_server-protocol (3.17.0.3)
|
||||
launchy (3.0.1)
|
||||
language_server-protocol (3.17.0.4)
|
||||
launchy (3.1.0)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.6.2)
|
||||
loofah (2.23.1)
|
||||
logger (1.6.5)
|
||||
loofah (2.24.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -248,13 +256,13 @@ GEM
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.4)
|
||||
mocha (2.7.0)
|
||||
mocha (2.7.1)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
msgpack (1.7.5)
|
||||
multipart-post (2.4.1)
|
||||
net-http (0.5.0)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.0)
|
||||
net-imap (0.5.5)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -264,81 +272,91 @@ GEM
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.17.0-aarch64-linux)
|
||||
nokogiri (1.18.2-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.17.0-arm-linux)
|
||||
nokogiri (1.18.2-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.17.0-arm64-darwin)
|
||||
nokogiri (1.18.2-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.17.0-x86-linux)
|
||||
nokogiri (1.18.2-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.17.0-x86_64-darwin)
|
||||
nokogiri (1.18.2-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.17.0-x86_64-linux)
|
||||
nokogiri (1.18.2-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
octokit (9.2.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (9.3.3)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.5.0)
|
||||
parser (3.3.7.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
plaid (34.0.0)
|
||||
plaid (35.1.0)
|
||||
faraday (>= 1.0.1, < 3.0)
|
||||
faraday-multipart (>= 1.0.1, < 2.0)
|
||||
prism (1.2.0)
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.3.0)
|
||||
propshaft (1.1.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
railties (>= 7.0.0)
|
||||
psych (5.2.1)
|
||||
psych (5.2.3)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.5.0)
|
||||
puma (6.6.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.8)
|
||||
rack-session (2.0.0)
|
||||
rack (3.1.9)
|
||||
rack-mini-profiler (3.3.1)
|
||||
rack (>= 1.2.0)
|
||||
rack-session (2.1.0)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (7.2.2)
|
||||
actioncable (= 7.2.2)
|
||||
actionmailbox (= 7.2.2)
|
||||
actionmailer (= 7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
actiontext (= 7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activemodel (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
rails (7.2.2.1)
|
||||
actioncable (= 7.2.2.1)
|
||||
actionmailbox (= 7.2.2.1)
|
||||
actionmailer (= 7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
actiontext (= 7.2.2.1)
|
||||
actionview (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activemodel (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activestorage (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.2)
|
||||
railties (= 7.2.2.1)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.1)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (7.0.9)
|
||||
rails-i18n (7.0.10)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 6.0.0, < 8)
|
||||
rails-settings-cached (2.9.5)
|
||||
rails-settings-cached (2.9.6)
|
||||
activerecord (>= 5.0.0)
|
||||
railties (>= 5.0.0)
|
||||
railties (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
railties (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -349,104 +367,105 @@ GEM
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (3.6.1)
|
||||
rbs (3.8.1)
|
||||
logger
|
||||
rdoc (6.8.1)
|
||||
rdoc (6.11.0)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.0)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.12)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.3.9)
|
||||
rubocop (1.67.0)
|
||||
rexml (3.4.0)
|
||||
rubocop (1.71.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.4, < 3.0)
|
||||
rubocop-ast (>= 1.32.2, < 2.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.36.2, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.32.3)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.38.0)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-minitest (0.35.0)
|
||||
rubocop-minitest (0.36.0)
|
||||
rubocop (>= 1.61, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-performance (1.21.0)
|
||||
rubocop-performance (1.23.1)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails (2.25.0)
|
||||
rubocop-rails (2.29.1)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.33.0, < 2.0)
|
||||
rubocop (>= 1.52.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails-omakase (1.0.0)
|
||||
rubocop
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.22.1)
|
||||
ruby-lsp (0.23.6)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 1.2, < 2.0)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.3.27)
|
||||
ruby-lsp (>= 0.22.0, < 0.23.0)
|
||||
ruby-lsp-rails (0.3.31)
|
||||
ruby-lsp (>= 0.23.0, < 0.24.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.2)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
rubyzip (2.4.1)
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
securerandom (0.4.0)
|
||||
selenium-webdriver (4.27.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.28.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.22.0)
|
||||
sentry-rails (5.22.3)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.22.0)
|
||||
sentry-ruby (5.22.0)
|
||||
sentry-ruby (~> 5.22.3)
|
||||
sentry-ruby (5.22.3)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.12.3)
|
||||
simplecov-html (0.13.1)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11663)
|
||||
stackprof (0.2.26)
|
||||
sorbet-runtime (0.5.11781)
|
||||
stackprof (0.2.27)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.2)
|
||||
stripe (13.2.0)
|
||||
tailwindcss-rails (3.0.0)
|
||||
stripe (13.4.1)
|
||||
tailwindcss-rails (3.3.1)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby
|
||||
tailwindcss-ruby (3.4.14)
|
||||
tailwindcss-ruby (3.4.14-aarch64-linux)
|
||||
tailwindcss-ruby (3.4.14-arm-linux)
|
||||
tailwindcss-ruby (3.4.14-arm64-darwin)
|
||||
tailwindcss-ruby (3.4.14-x86_64-darwin)
|
||||
tailwindcss-ruby (3.4.14-x86_64-linux)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
tailwindcss-ruby (~> 3.0)
|
||||
tailwindcss-ruby (3.4.17-aarch64-linux)
|
||||
tailwindcss-ruby (3.4.17-arm-linux)
|
||||
tailwindcss-ruby (3.4.17-arm64-darwin)
|
||||
tailwindcss-ruby (3.4.17-x86_64-darwin)
|
||||
tailwindcss-ruby (3.4.17-x86_64-linux)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
thor (1.3.2)
|
||||
timeout (0.4.2)
|
||||
timeout (0.4.3)
|
||||
turbo-rails (2.0.11)
|
||||
actionpack (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (2.6.0)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.2)
|
||||
useragent (0.16.11)
|
||||
vcr (6.3.1)
|
||||
@@ -461,7 +480,8 @@ GEM
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-driver (0.7.7)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
@@ -470,15 +490,21 @@ GEM
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
aarch64-linux-gnu
|
||||
aarch64-linux-musl
|
||||
arm-linux
|
||||
arm-linux-gnu
|
||||
arm-linux-musl
|
||||
arm64-darwin
|
||||
x86-linux
|
||||
x86_64-darwin
|
||||
x86_64-linux
|
||||
x86_64-linux-gnu
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
aws-sdk-s3
|
||||
aws-sdk-s3 (~> 1.177.0)
|
||||
bcrypt (~> 3.1)
|
||||
benchmark-ips
|
||||
bootsnap
|
||||
brakeman
|
||||
capybara
|
||||
@@ -509,6 +535,7 @@ DEPENDENCIES
|
||||
plaid
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
rack-mini-profiler
|
||||
rails (~> 7.2.2)
|
||||
rails-settings-cached
|
||||
redcarpet
|
||||
@@ -529,7 +556,7 @@ DEPENDENCIES
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.5p100
|
||||
ruby 3.4.1p0
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.22
|
||||
2.6.3
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
@apply focus:opacity-100 focus:outline-none focus:ring-0;
|
||||
@apply placeholder-shown:opacity-50;
|
||||
@apply disabled:text-gray-400;
|
||||
@apply text-ellipsis overflow-hidden whitespace-nowrap;
|
||||
}
|
||||
|
||||
select.form-field__input {
|
||||
@apply pr-8;
|
||||
}
|
||||
|
||||
.form-field__radio {
|
||||
@@ -51,16 +56,24 @@
|
||||
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--light:disabled {
|
||||
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark {
|
||||
@apply ring-gray-900 checked:text-white;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark:disabled {
|
||||
@apply cursor-not-allowed opacity-80 ring-gray-600;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark:checked {
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
select[multiple="multiple"] {
|
||||
@apply py-2 pr-2 space-y-0.5;
|
||||
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
class Account::EntriesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
|
||||
def index
|
||||
@q = search_params
|
||||
@pagy, @entries = pagy(entries_scope.search(@q).reverse_chronological, limit: params[:per_page] || "10")
|
||||
end
|
||||
|
||||
private
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def entries_scope
|
||||
scope = Current.family.entries
|
||||
scope = scope.where(account: @account) if @account
|
||||
scope
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.fetch(:q, {})
|
||||
.permit(:search)
|
||||
end
|
||||
end
|
||||
@@ -21,24 +21,6 @@ class Account::TransactionsController < ApplicationController
|
||||
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
|
||||
|
||||
private
|
||||
def bulk_delete_params
|
||||
params.require(:bulk_delete).permit(entry_ids: [])
|
||||
|
||||
56
app/controllers/account/transfer_matches_controller.rb
Normal file
56
app/controllers/account/transfer_matches_controller.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
class Account::TransferMatchesController < ApplicationController
|
||||
before_action :set_entry
|
||||
|
||||
def new
|
||||
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
|
||||
@transfer_match_candidates = @entry.transfer_match_candidates
|
||||
end
|
||||
|
||||
def create
|
||||
@transfer = build_transfer
|
||||
@transfer.save!
|
||||
@transfer.sync_account_later
|
||||
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def set_entry
|
||||
@entry = Current.family.entries.find(params[:transaction_id])
|
||||
end
|
||||
|
||||
def transfer_match_params
|
||||
params.require(:transfer_match).permit(:method, :matched_entry_id, :target_account_id)
|
||||
end
|
||||
|
||||
def build_transfer
|
||||
if transfer_match_params[:method] == "new"
|
||||
target_account = Current.family.accounts.find(transfer_match_params[:target_account_id])
|
||||
|
||||
missing_transaction = Account::Transaction.new(
|
||||
entry: target_account.entries.build(
|
||||
amount: @entry.amount * -1,
|
||||
currency: @entry.currency,
|
||||
date: @entry.date,
|
||||
name: "Transfer to #{@entry.amount.negative? ? @entry.account.name : target_account.name}",
|
||||
)
|
||||
)
|
||||
|
||||
transfer = Transfer.find_or_initialize_by(
|
||||
inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.account_transaction,
|
||||
outflow_transaction: @entry.amount.positive? ? @entry.account_transaction : missing_transaction
|
||||
)
|
||||
transfer.status = "confirmed"
|
||||
transfer
|
||||
else
|
||||
target_transaction = Current.family.entries.find(transfer_match_params[:matched_entry_id])
|
||||
|
||||
transfer = Transfer.find_or_initialize_by(
|
||||
inflow_transaction: @entry.amount.negative? ? @entry.account_transaction : target_transaction.account_transaction,
|
||||
outflow_transaction: @entry.amount.negative? ? target_transaction.account_transaction : @entry.account_transaction
|
||||
)
|
||||
transfer.status = "confirmed"
|
||||
transfer
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,61 +0,0 @@
|
||||
class Account::TransfersController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_transfer, only: %i[destroy show update]
|
||||
|
||||
def new
|
||||
@transfer = Account::Transfer.new
|
||||
end
|
||||
|
||||
def show
|
||||
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
|
||||
|
||||
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[:alert] = @transfer.errors.full_messages.to_sentence
|
||||
redirect_to transactions_path
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@transfer.update_entries!(transfer_update_params)
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transfer.destroy!
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_transfer
|
||||
record = Account::Transfer.find(params[:id])
|
||||
|
||||
unless record.entries.all? { |entry| Current.family.accounts.include?(entry.account) }
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
|
||||
@transfer = record
|
||||
end
|
||||
|
||||
def transfer_params
|
||||
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
|
||||
end
|
||||
|
||||
def transfer_update_params
|
||||
params.require(:account_transfer).permit(:excluded, :notes)
|
||||
end
|
||||
end
|
||||
@@ -4,8 +4,8 @@ class AccountsController < ApplicationController
|
||||
before_action :set_account, only: %i[sync]
|
||||
|
||||
def index
|
||||
@manual_accounts = Current.family.accounts.where(scheduled_for_deletion: false).manual.alphabetically
|
||||
@plaid_items = Current.family.plaid_items.where(scheduled_for_deletion: false).ordered
|
||||
@manual_accounts = Current.family.accounts.manual.alphabetically
|
||||
@plaid_items = Current.family.plaid_items.ordered
|
||||
end
|
||||
|
||||
def summary
|
||||
|
||||
@@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base
|
||||
return false unless Current.session
|
||||
return false if Current.family.subscribed?
|
||||
return false if subscription_pending? || request.path == settings_billing_path
|
||||
return false if Current.family.active_accounts_count <= 3
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
35
app/controllers/budget_categories_controller.rb
Normal file
35
app/controllers/budget_categories_controller.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class BudgetCategoriesController < ApplicationController
|
||||
def index
|
||||
@budget = Current.family.budgets.find(params[:budget_id])
|
||||
render layout: "wizard"
|
||||
end
|
||||
|
||||
def show
|
||||
@budget = Current.family.budgets.find(params[:budget_id])
|
||||
|
||||
@recent_transactions = @budget.entries
|
||||
|
||||
if params[:id] == BudgetCategory.uncategorized.id
|
||||
@budget_category = @budget.uncategorized_budget_category
|
||||
@recent_transactions = @recent_transactions.where(account_transactions: { category_id: nil })
|
||||
else
|
||||
@budget_category = Current.family.budget_categories.find(params[:id])
|
||||
@recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
|
||||
.where("categories.id = ? OR categories.parent_id = ?", @budget_category.category.id, @budget_category.category.id)
|
||||
end
|
||||
|
||||
@recent_transactions = @recent_transactions.order("account_entries.date DESC, ABS(account_entries.amount) DESC").take(3)
|
||||
end
|
||||
|
||||
def update
|
||||
@budget_category = Current.family.budget_categories.find(params[:id])
|
||||
@budget_category.update!(budget_category_params)
|
||||
|
||||
redirect_to budget_budget_categories_path(@budget_category.budget)
|
||||
end
|
||||
|
||||
private
|
||||
def budget_category_params
|
||||
params.require(:budget_category).permit(:budgeted_spending)
|
||||
end
|
||||
end
|
||||
55
app/controllers/budgets_controller.rb
Normal file
55
app/controllers/budgets_controller.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
class BudgetsController < ApplicationController
|
||||
before_action :set_budget, only: %i[show edit update]
|
||||
|
||||
def index
|
||||
redirect_to_current_month_budget
|
||||
end
|
||||
|
||||
def show
|
||||
@next_budget = @budget.next_budget
|
||||
@previous_budget = @budget.previous_budget
|
||||
@latest_budget = Budget.find_or_bootstrap(Current.family)
|
||||
render layout: with_sidebar
|
||||
end
|
||||
|
||||
def edit
|
||||
render layout: "wizard"
|
||||
end
|
||||
|
||||
def update
|
||||
@budget.update!(budget_params)
|
||||
redirect_to budget_budget_categories_path(@budget)
|
||||
end
|
||||
|
||||
def create
|
||||
start_date = Date.parse(budget_create_params[:start_date])
|
||||
@budget = Budget.find_or_bootstrap(Current.family, date: start_date)
|
||||
redirect_to budget_path(@budget)
|
||||
end
|
||||
|
||||
def picker
|
||||
render partial: "budgets/picker", locals: {
|
||||
family: Current.family,
|
||||
year: params[:year].to_i || Date.current.year
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def budget_create_params
|
||||
params.require(:budget).permit(:start_date)
|
||||
end
|
||||
|
||||
def budget_params
|
||||
params.require(:budget).permit(:budgeted_spending, :expected_income)
|
||||
end
|
||||
|
||||
def set_budget
|
||||
@budget = Current.family.budgets.find(params[:id])
|
||||
@budget.sync_budget_categories
|
||||
end
|
||||
|
||||
def redirect_to_current_month_budget
|
||||
current_budget = Budget.find_or_bootstrap(Current.family)
|
||||
redirect_to budget_path(current_budget)
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,7 @@ class CategoriesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_category, only: %i[edit update destroy]
|
||||
before_action :set_categories, only: %i[update edit]
|
||||
before_action :set_transaction, only: :create
|
||||
|
||||
def index
|
||||
@@ -10,6 +11,7 @@ class CategoriesController < ApplicationController
|
||||
|
||||
def new
|
||||
@category = Current.family.categories.new color: Category::COLORS.sample
|
||||
set_categories
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -17,9 +19,17 @@ class CategoriesController < ApplicationController
|
||||
|
||||
if @category.save
|
||||
@transaction.update(category_id: @category.id) if @transaction
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
|
||||
flash[:notice] = t(".success")
|
||||
|
||||
redirect_target_url = request.referer || categories_path
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to categories_path, notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
else
|
||||
redirect_back_or_to transactions_path, alert: t(".failure", error: @category.errors.full_messages.to_sentence)
|
||||
set_categories
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,9 +37,17 @@ class CategoriesController < ApplicationController
|
||||
end
|
||||
|
||||
def update
|
||||
@category.update! category_params
|
||||
if @category.update(category_params)
|
||||
flash[:notice] = t(".success")
|
||||
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
redirect_target_url = request.referer || categories_path
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to categories_path, notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -38,11 +56,25 @@ class CategoriesController < ApplicationController
|
||||
redirect_back_or_to categories_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def bootstrap
|
||||
Current.family.categories.bootstrap_defaults
|
||||
|
||||
redirect_back_or_to categories_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def set_category
|
||||
@category = Current.family.categories.find(params[:id])
|
||||
end
|
||||
|
||||
def set_categories
|
||||
@categories = unless @category.parent?
|
||||
Current.family.categories.alphabetically.roots.where.not(id: @category.id)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def set_transaction
|
||||
if params[:transaction_id].present?
|
||||
@transaction = Current.family.transactions.find(params[:transaction_id])
|
||||
@@ -50,6 +82,6 @@ class CategoriesController < ApplicationController
|
||||
end
|
||||
|
||||
def category_params
|
||||
params.require(:category).permit(:name, :color)
|
||||
params.require(:category).permit(:name, :color, :parent_id, :classification, :lucide_icon)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,8 @@ module AccountableResource
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include ScrollFocusable
|
||||
|
||||
layout :with_sidebar
|
||||
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
|
||||
before_action :set_link_token, only: :new
|
||||
@@ -22,6 +24,12 @@ module AccountableResource
|
||||
end
|
||||
|
||||
def show
|
||||
@q = params.fetch(:q, {}).permit(:search)
|
||||
entries = @account.entries.search(@q).reverse_chronological
|
||||
|
||||
set_focused_record(entries, params[:focused_record_id])
|
||||
|
||||
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10", params: ->(params) { params.except(:focused_record_id) })
|
||||
end
|
||||
|
||||
def edit
|
||||
@@ -44,11 +52,21 @@ module AccountableResource
|
||||
|
||||
private
|
||||
def set_link_token
|
||||
@link_token = Current.family.get_link_token(
|
||||
@us_link_token = Current.family.get_link_token(
|
||||
webhooks_url: webhooks_url,
|
||||
redirect_url: accounts_url,
|
||||
accountable_type: accountable_type.name
|
||||
accountable_type: accountable_type.name,
|
||||
region: :us
|
||||
)
|
||||
|
||||
if Current.family.eu?
|
||||
@eu_link_token = Current.family.get_link_token(
|
||||
webhooks_url: webhooks_url,
|
||||
redirect_url: accounts_url,
|
||||
accountable_type: accountable_type.name,
|
||||
region: :eu
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def webhooks_url
|
||||
|
||||
@@ -52,11 +52,14 @@ module EntryableResource
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"header_account_entry_#{@entry.id}",
|
||||
partial: "#{entryable_type.name.underscore.pluralize}/header",
|
||||
locals: { entry: @entry }
|
||||
)
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"header_account_entry_#{@entry.id}",
|
||||
partial: "#{entryable_type.name.underscore.pluralize}/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry })
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
@@ -119,7 +122,7 @@ module EntryableResource
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(
|
||||
:account_id, :name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
:account_id, :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
entryable_attributes: self.class.permitted_entryable_attributes
|
||||
)
|
||||
end
|
||||
|
||||
21
app/controllers/concerns/scroll_focusable.rb
Normal file
21
app/controllers/concerns/scroll_focusable.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
module ScrollFocusable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def set_focused_record(record_scope, record_id, default_per_page: 10)
|
||||
return unless record_id.present?
|
||||
|
||||
@focused_record = record_scope.find_by(id: record_id)
|
||||
|
||||
record_index = record_scope.pluck(:id).index(record_id)
|
||||
|
||||
return unless record_index
|
||||
|
||||
page_of_focused_record = (record_index / (params[:per_page]&.to_i || default_per_page)) + 1
|
||||
|
||||
if params[:page]&.to_i != page_of_focused_record
|
||||
(
|
||||
redirect_to(url_for(page: page_of_focused_record, focused_record_id: record_id))
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
18
app/controllers/email_confirmations_controller.rb
Normal file
18
app/controllers/email_confirmations_controller.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
class EmailConfirmationsController < ApplicationController
|
||||
skip_before_action :set_request_details, only: :new
|
||||
skip_authentication only: :new
|
||||
|
||||
def new
|
||||
# Returns nil if the token is invalid OR expired
|
||||
@user = User.find_by_token_for(:email_confirmation, params[:token])
|
||||
|
||||
if @user&.unconfirmed_email && @user&.update(
|
||||
email: @user.unconfirmed_email,
|
||||
unconfirmed_email: nil
|
||||
)
|
||||
redirect_to new_session_path, notice: t(".success_login")
|
||||
else
|
||||
redirect_to root_path, alert: t(".invalid_token")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -34,6 +34,24 @@ class InvitationsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
unless Current.user.admin?
|
||||
flash[:alert] = t("invitations.destroy.not_authorized")
|
||||
redirect_to settings_profile_path
|
||||
return
|
||||
end
|
||||
|
||||
@invitation = Current.family.invitations.find(params[:id])
|
||||
|
||||
if @invitation.destroy
|
||||
flash[:notice] = t("invitations.destroy.success")
|
||||
else
|
||||
flash[:alert] = t("invitations.destroy.failure")
|
||||
end
|
||||
|
||||
redirect_to settings_profile_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def invitation_params
|
||||
|
||||
@@ -6,6 +6,7 @@ class InviteCodesController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
raise StandardError, "You are not allowed to generate invite codes" unless Current.user.admin?
|
||||
InviteCode.generate!
|
||||
redirect_back_or_to invite_codes_path, notice: "Code generated"
|
||||
end
|
||||
|
||||
@@ -21,7 +21,7 @@ class PagesController < ApplicationController
|
||||
|
||||
@accounts = Current.family.accounts.active
|
||||
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
@transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological
|
||||
@transaction_entries = Current.family.entries.incomes_and_expenses.limit(6).reverse_chronological
|
||||
|
||||
# TODO: Placeholders for trendlines
|
||||
placeholder_series_data = 10.times.map do |i|
|
||||
|
||||
@@ -5,6 +5,7 @@ class PlaidItemsController < ApplicationController
|
||||
Current.family.plaid_items.create_from_public_token(
|
||||
plaid_item_params[:public_token],
|
||||
item_name: item_name,
|
||||
region: plaid_item_params[:region]
|
||||
)
|
||||
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
@@ -29,7 +30,7 @@ class PlaidItemsController < ApplicationController
|
||||
end
|
||||
|
||||
def plaid_item_params
|
||||
params.require(:plaid_item).permit(:public_token, metadata: {})
|
||||
params.require(:plaid_item).permit(:public_token, :region, metadata: {})
|
||||
end
|
||||
|
||||
def item_name
|
||||
|
||||
@@ -24,7 +24,6 @@ class RegistrationsController < ApplicationController
|
||||
|
||||
if @user.save
|
||||
@invitation&.update!(accepted_at: Time.current)
|
||||
Category.create_default_categories(@user.family) unless @invitation
|
||||
@session = create_session_for(@user)
|
||||
redirect_to root_path, notice: t(".success")
|
||||
else
|
||||
|
||||
@@ -5,14 +5,7 @@ class SecuritiesController < ApplicationController
|
||||
|
||||
@securities = Security.search({
|
||||
search: query,
|
||||
country: country_code_filter
|
||||
country: params[:country_code] == "US" ? "US" : nil
|
||||
})
|
||||
end
|
||||
|
||||
private
|
||||
def country_code_filter
|
||||
filter = params[:country_code]
|
||||
filter = "#{filter},US" unless filter == "US"
|
||||
filter
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,6 +22,10 @@ class Settings::HostingsController < SettingsController
|
||||
Setting.require_invite_for_signup = hosting_params[:require_invite_for_signup]
|
||||
end
|
||||
|
||||
if hosting_params.key?(:require_email_confirmation)
|
||||
Setting.require_email_confirmation = hosting_params[:require_email_confirmation]
|
||||
end
|
||||
|
||||
if hosting_params.key?(:synth_api_key)
|
||||
Setting.synth_api_key = hosting_params[:synth_api_key]
|
||||
end
|
||||
@@ -34,7 +38,7 @@ class Settings::HostingsController < SettingsController
|
||||
|
||||
private
|
||||
def hosting_params
|
||||
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :synth_api_key)
|
||||
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key)
|
||||
end
|
||||
|
||||
def raise_if_not_self_hosted
|
||||
|
||||
@@ -4,4 +4,28 @@ class Settings::ProfilesController < SettingsController
|
||||
@users = Current.family.users.order(:created_at)
|
||||
@pending_invitations = Current.family.invitations.pending
|
||||
end
|
||||
|
||||
def destroy
|
||||
unless Current.user.admin?
|
||||
flash[:alert] = t("settings.profiles.destroy.not_authorized")
|
||||
redirect_to settings_profile_path
|
||||
return
|
||||
end
|
||||
|
||||
@user = Current.family.users.find(params[:user_id])
|
||||
|
||||
if @user == Current.user
|
||||
flash[:alert] = t("settings.profiles.destroy.cannot_remove_self")
|
||||
redirect_to settings_profile_path
|
||||
return
|
||||
end
|
||||
|
||||
if @user.destroy
|
||||
flash[:notice] = t("settings.profiles.destroy.member_removed")
|
||||
else
|
||||
flash[:alert] = t("settings.profiles.destroy.member_removal_failed")
|
||||
end
|
||||
|
||||
redirect_to settings_profile_path
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,25 +1,110 @@
|
||||
class TransactionsController < ApplicationController
|
||||
include ScrollFocusable
|
||||
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :store_params!, only: :index
|
||||
|
||||
def index
|
||||
@q = search_params
|
||||
result = Current.family.entries.account_transactions.search(@q).reverse_chronological
|
||||
@pagy, @transaction_entries = pagy(result, limit: params[:per_page] || "50")
|
||||
search_query = Current.family.transactions.search(@q)
|
||||
|
||||
@totals = {
|
||||
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)
|
||||
set_focused_record(search_query, params[:focused_record_id], default_per_page: 50)
|
||||
|
||||
@pagy, @transaction_entries = pagy(
|
||||
search_query.reverse_chronological.preload(
|
||||
:account,
|
||||
entryable: [
|
||||
:category, :merchant, :tags,
|
||||
:transfer_as_inflow,
|
||||
transfer_as_outflow: {
|
||||
inflow_transaction: { entry: :account },
|
||||
outflow_transaction: { entry: :account }
|
||||
}
|
||||
]
|
||||
),
|
||||
limit: params[:per_page].presence || default_params[:per_page],
|
||||
params: ->(params) { params.except(:focused_record_id) }
|
||||
)
|
||||
|
||||
@transfers = @transaction_entries.map { |entry| entry.entryable.transfer_as_outflow }.compact
|
||||
@totals = search_query.stats(Current.family.currency)
|
||||
end
|
||||
|
||||
def clear_filter
|
||||
updated_params = {
|
||||
"q" => search_params,
|
||||
"page" => params[:page],
|
||||
"per_page" => params[:per_page]
|
||||
}
|
||||
|
||||
q_params = updated_params["q"] || {}
|
||||
|
||||
param_key = params[:param_key]
|
||||
param_value = params[:param_value]
|
||||
|
||||
if q_params[param_key].is_a?(Array)
|
||||
q_params[param_key].delete(param_value)
|
||||
q_params.delete(param_key) if q_params[param_key].empty?
|
||||
else
|
||||
q_params.delete(param_key)
|
||||
end
|
||||
|
||||
updated_params["q"] = q_params.presence
|
||||
Current.session.update!(prev_transaction_page_params: updated_params)
|
||||
|
||||
redirect_to transactions_path(updated_params)
|
||||
end
|
||||
|
||||
private
|
||||
def search_params
|
||||
params.fetch(:q, {})
|
||||
cleaned_params = params.fetch(:q, {})
|
||||
.permit(
|
||||
:start_date, :end_date, :search, :amount,
|
||||
:amount_operator, accounts: [], account_ids: [],
|
||||
categories: [], merchants: [], types: [], tags: []
|
||||
)
|
||||
.to_h
|
||||
.compact_blank
|
||||
|
||||
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
|
||||
|
||||
cleaned_params
|
||||
end
|
||||
|
||||
def store_params!
|
||||
if should_restore_params?
|
||||
params_to_restore = {}
|
||||
|
||||
params_to_restore[:q] = stored_params["q"].presence || default_params[:q]
|
||||
params_to_restore[:page] = stored_params["page"].presence || default_params[:page]
|
||||
params_to_restore[:per_page] = stored_params["per_page"].presence || default_params[:per_page]
|
||||
|
||||
redirect_to transactions_path(params_to_restore)
|
||||
else
|
||||
Current.session.update!(
|
||||
prev_transaction_page_params: {
|
||||
q: search_params,
|
||||
page: params[:page],
|
||||
per_page: params[:per_page]
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def should_restore_params?
|
||||
request.query_parameters.blank? && (stored_params["q"].present? || stored_params["page"].present? || stored_params["per_page"].present?)
|
||||
end
|
||||
|
||||
def stored_params
|
||||
Current.session.prev_transaction_page_params
|
||||
end
|
||||
|
||||
def default_params
|
||||
{
|
||||
q: {},
|
||||
page: 1,
|
||||
per_page: 50
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
74
app/controllers/transfers_controller.rb
Normal file
74
app/controllers/transfers_controller.rb
Normal file
@@ -0,0 +1,74 @@
|
||||
class TransfersController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_transfer, only: %i[destroy show update]
|
||||
|
||||
def new
|
||||
@transfer = Transfer.new
|
||||
end
|
||||
|
||||
def show
|
||||
@categories = Current.family.categories.expenses
|
||||
end
|
||||
|
||||
def create
|
||||
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
|
||||
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
|
||||
|
||||
@transfer = Transfer.from_accounts(
|
||||
from_account: from_account,
|
||||
to_account: to_account,
|
||||
date: transfer_params[:date],
|
||||
amount: transfer_params[:amount].to_d
|
||||
)
|
||||
|
||||
if @transfer.save
|
||||
@transfer.sync_account_later
|
||||
|
||||
flash[:notice] = t(".success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transactions_path }
|
||||
redirect_target_url = request.referer || transactions_path
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if transfer_update_params[:status] == "rejected"
|
||||
@transfer.reject!
|
||||
elsif transfer_update_params[:status] == "confirmed"
|
||||
@transfer.confirm!
|
||||
end
|
||||
|
||||
@transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id])
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transactions_url, notice: t(".success") }
|
||||
format.turbo_stream
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transfer.destroy!
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def set_transfer
|
||||
@transfer = Transfer.find(params[:id])
|
||||
|
||||
raise ActiveRecord::RecordNotFound unless @transfer.belongs_to_family?(Current.family)
|
||||
end
|
||||
|
||||
def transfer_params
|
||||
params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
|
||||
end
|
||||
|
||||
def transfer_update_params
|
||||
params.require(:transfer).permit(:notes, :status, :category_id)
|
||||
end
|
||||
end
|
||||
@@ -4,10 +4,23 @@ class UsersController < ApplicationController
|
||||
def update
|
||||
@user = Current.user
|
||||
|
||||
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
|
||||
@user.profile_image.purge if should_purge_profile_image?
|
||||
if email_changed?
|
||||
if @user.initiate_email_change(user_params[:email])
|
||||
if Rails.application.config.app_mode.self_hosted? && !Setting.require_email_confirmation
|
||||
handle_redirect(t(".success"))
|
||||
else
|
||||
redirect_to settings_profile_path, notice: t(".email_change_initiated")
|
||||
end
|
||||
else
|
||||
error_message = @user.errors.any? ? @user.errors.full_messages.to_sentence : t(".email_change_failed")
|
||||
redirect_to settings_profile_path, alert: error_message
|
||||
end
|
||||
else
|
||||
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
|
||||
@user.profile_image.purge if should_purge_profile_image?
|
||||
|
||||
handle_redirect(t(".success"))
|
||||
handle_redirect(t(".success"))
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -38,10 +51,14 @@ class UsersController < ApplicationController
|
||||
user_params[:profile_image].blank?
|
||||
end
|
||||
|
||||
def email_changed?
|
||||
user_params[:email].present? && user_params[:email] != @user.email
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(
|
||||
:first_name, :last_name, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
|
||||
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ]
|
||||
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
|
||||
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ]
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -3,23 +3,30 @@ module Account::EntriesHelper
|
||||
"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 entries_by_date(entries, selectable: true, totals: false)
|
||||
entries.reverse_chronological.group_by(&:date).map do |date, grouped_entries|
|
||||
def entries_by_date(entries, transfers: [], selectable: true, totals: false)
|
||||
entries.group_by(&:date).map do |date, grouped_entries|
|
||||
content = capture do
|
||||
yield grouped_entries
|
||||
yield [ grouped_entries, transfers.select { |t| t.outflow_transaction.entry.date == date } ]
|
||||
end
|
||||
|
||||
next if content.blank?
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable:, totals: }
|
||||
end.join.html_safe
|
||||
end.compact.join.html_safe
|
||||
end
|
||||
|
||||
def entry_name_detailed(entry)
|
||||
[
|
||||
entry.date,
|
||||
format_money(entry.amount_money),
|
||||
entry.account.name,
|
||||
entry.display_name
|
||||
].join(" • ")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
module Account::TransfersHelper
|
||||
end
|
||||
@@ -8,6 +8,18 @@ module AccountsHelper
|
||||
|
||||
days_apart = (end_date - start_date).to_i
|
||||
|
||||
# Handle specific cases
|
||||
if start_date == Date.current.beginning_of_week && end_date == Date.current
|
||||
return "Current Week to Date (CWD)"
|
||||
elsif start_date == Date.current.beginning_of_month && end_date == Date.current
|
||||
return "Current Month to Date (MTD)"
|
||||
elsif start_date == Date.current.beginning_of_quarter && end_date == Date.current
|
||||
return "Current Quarter to Date (CQD)"
|
||||
elsif start_date == Date.current.beginning_of_year && end_date == Date.current
|
||||
return "Current Year to Date (YTD)"
|
||||
end
|
||||
|
||||
# Default cases
|
||||
case days_apart
|
||||
when 1
|
||||
"vs. yesterday"
|
||||
@@ -15,6 +27,8 @@ module AccountsHelper
|
||||
"vs. last week"
|
||||
when 30, 31
|
||||
"vs. last month"
|
||||
when 90
|
||||
"vs. last 3 months"
|
||||
when 365, 366
|
||||
"vs. last year"
|
||||
else
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
module ApplicationHelper
|
||||
include Pagy::Frontend
|
||||
|
||||
def date_format_options
|
||||
[
|
||||
[ "DD-MM-YYYY", "%d-%m-%Y" ],
|
||||
[ "DD.MM.YY", "%d.%m.%Y" ],
|
||||
[ "MM-DD-YYYY", "%m-%d-%Y" ],
|
||||
[ "YYYY-MM-DD", "%Y-%m-%d" ],
|
||||
[ "DD/MM/YYYY", "%d/%m/%Y" ],
|
||||
[ "YYYY/MM/DD", "%Y/%m/%d" ],
|
||||
[ "MM/DD/YYYY", "%m/%d/%Y" ],
|
||||
[ "D/MM/YYYY", "%e/%m/%Y" ],
|
||||
[ "YYYY.MM.DD", "%Y.%m.%d" ]
|
||||
]
|
||||
def icon(key, size: "md", color: "current")
|
||||
render partial: "shared/icon", locals: { key:, size:, color: }
|
||||
end
|
||||
|
||||
# Convert alpha (0-1) to 8-digit hex (00-FF)
|
||||
def hex_with_alpha(hex, alpha)
|
||||
alpha_hex = (alpha * 255).round.to_s(16).rjust(2, "0")
|
||||
"#{hex}#{alpha_hex}"
|
||||
end
|
||||
|
||||
def title(page_title)
|
||||
@@ -67,9 +63,9 @@ module ApplicationHelper
|
||||
render partial: "shared/drawer", locals: { content:, reload_on_close: }
|
||||
end
|
||||
|
||||
def disclosure(title, &block)
|
||||
def disclosure(title, default_open: true, &block)
|
||||
content = capture &block
|
||||
render partial: "shared/disclosure", locals: { title: title, content: content }
|
||||
render partial: "shared/disclosure", locals: { title: title, content: content, open: default_open }
|
||||
end
|
||||
|
||||
def sidebar_link_to(name, path, options = {})
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
module CategoriesHelper
|
||||
def null_category
|
||||
def transfer_category
|
||||
Category.new \
|
||||
name: "Uncategorized",
|
||||
color: Category::UNCATEGORIZED_COLOR
|
||||
name: "Transfer",
|
||||
color: Category::TRANSFER_COLOR,
|
||||
lucide_icon: "arrow-right-left"
|
||||
end
|
||||
|
||||
def payment_category
|
||||
Category.new \
|
||||
name: "Payment",
|
||||
color: Category::PAYMENT_COLOR,
|
||||
lucide_icon: "arrow-right"
|
||||
end
|
||||
|
||||
def trade_category
|
||||
Category.new \
|
||||
name: "Trade",
|
||||
color: Category::TRADE_COLOR
|
||||
end
|
||||
|
||||
def family_categories
|
||||
[ null_category ].concat(Current.family.categories.alphabetically)
|
||||
[ Category.uncategorized ].concat(Current.family.categories.alphabetically)
|
||||
end
|
||||
end
|
||||
|
||||
2
app/helpers/email_confirmations_helper.rb
Normal file
2
app/helpers/email_confirmations_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module EmailConfirmationsHelper
|
||||
end
|
||||
@@ -18,9 +18,20 @@ module FormsHelper
|
||||
end
|
||||
|
||||
def period_select(form:, selected:, classes: "border border-alpha-black-100 shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-gray-900 focus:outline-none focus:ring-0")
|
||||
periods_for_select = [ [ "7D", "last_7_days" ], [ "1M", "last_30_days" ], [ "1Y", "last_365_days" ] ]
|
||||
periods_for_select = [
|
||||
%w[CWD current_week], # Current Week to Date
|
||||
%w[7D last_7_days],
|
||||
%w[MTD current_month], # Month to Date
|
||||
%w[1M last_30_days],
|
||||
%w[CQD current_quarter], # Quarter to Date
|
||||
%w[3M last_90_days],
|
||||
%w[YTD current_year], # Year to Date
|
||||
%w[1Y last_365_days]
|
||||
]
|
||||
|
||||
form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" })
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
def currencies_for_select
|
||||
Money::Currency.all_instances.sort_by { |currency| [ currency.priority, currency.name ] }
|
||||
|
||||
@@ -365,6 +365,11 @@ module LanguagesHelper
|
||||
end
|
||||
|
||||
def timezone_options
|
||||
ActiveSupport::TimeZone.all.map { |tz| [ tz.name + " (#{tz.tzinfo.identifier})", tz.tzinfo.identifier ] }
|
||||
ActiveSupport::TimeZone.all
|
||||
.sort_by { |tz| [ tz.utc_offset, tz.name ] }
|
||||
.map do |tz|
|
||||
name = tz.name.split(" - ").first.gsub(" (US & Canada)", "")
|
||||
[ "(#{tz.formatted_offset}) #{name}", tz.tzinfo.identifier ]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,21 +18,4 @@ module TransactionsHelper
|
||||
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
|
||||
|
||||
25
app/javascript/controllers/budget_form_controller.js
Normal file
25
app/javascript/controllers/budget_form_controller.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="budget-form"
|
||||
export default class extends Controller {
|
||||
toggleAutoFill(e) {
|
||||
const expectedIncome = e.params.income;
|
||||
const budgetedSpending = e.params.spending;
|
||||
|
||||
if (e.target.checked) {
|
||||
this.#fillField(expectedIncome.key, expectedIncome.value);
|
||||
this.#fillField(budgetedSpending.key, budgetedSpending.value);
|
||||
} else {
|
||||
this.#clearField(expectedIncome.key);
|
||||
this.#clearField(budgetedSpending.key);
|
||||
}
|
||||
}
|
||||
|
||||
#fillField(id, value) {
|
||||
this.element.querySelector(`input[id="${id}"]`).value = value;
|
||||
}
|
||||
|
||||
#clearField(id) {
|
||||
this.element.querySelector(`input[id="${id}"]`).value = "";
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,9 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
_rowsForGroup(group) {
|
||||
return this.rowTargets.filter((row) => group.contains(row));
|
||||
return this.rowTargets.filter(
|
||||
(row) => group.contains(row) && !row.disabled,
|
||||
);
|
||||
}
|
||||
|
||||
_addToSelection(idToAdd) {
|
||||
@@ -115,7 +117,9 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
_selectAll() {
|
||||
this.selectedIdsValue = this.rowTargets.map((t) => t.dataset.id);
|
||||
this.selectedIdsValue = this.rowTargets
|
||||
.filter((t) => !t.disabled)
|
||||
.map((t) => t.dataset.id);
|
||||
}
|
||||
|
||||
_updateView = () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus";
|
||||
// Connects to data-controller="color-avatar"
|
||||
// Used by the transaction merchant form to show a preview of what the avatar will look like
|
||||
export default class extends Controller {
|
||||
static targets = ["name", "avatar"];
|
||||
static targets = ["name", "avatar", "selection"];
|
||||
|
||||
connect() {
|
||||
this.nameTarget.addEventListener("input", this.handleNameChange);
|
||||
@@ -25,4 +25,10 @@ export default class extends Controller {
|
||||
this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`;
|
||||
this.avatarTarget.style.color = color;
|
||||
}
|
||||
|
||||
handleParentChange(e) {
|
||||
const parent = e.currentTarget.value;
|
||||
const visibility = typeof parent === "string" && parent !== "" ? "hidden" : "visible"
|
||||
this.selectionTarget.style.visibility = visibility
|
||||
}
|
||||
}
|
||||
|
||||
168
app/javascript/controllers/donut_chart_controller.js
Normal file
168
app/javascript/controllers/donut_chart_controller.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import * as d3 from "d3";
|
||||
|
||||
// Connects to data-controller="donut-chart"
|
||||
export default class extends Controller {
|
||||
static targets = ["chartContainer", "contentContainer", "defaultContent"];
|
||||
static values = {
|
||||
segments: { type: Array, default: [] },
|
||||
unusedSegmentId: { type: String, default: "unused" },
|
||||
overageSegmentId: { type: String, default: "overage" },
|
||||
segmentHeight: { type: Number, default: 3 },
|
||||
segmentOpacity: { type: Number, default: 1 },
|
||||
};
|
||||
|
||||
#viewBoxSize = 100;
|
||||
#minSegmentAngle = this.segmentHeightValue * 0.01;
|
||||
|
||||
connect() {
|
||||
this.#draw();
|
||||
document.addEventListener("turbo:load", this.#redraw);
|
||||
this.element.addEventListener("mouseleave", this.#clearSegmentHover);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#teardown();
|
||||
document.removeEventListener("turbo:load", this.#redraw);
|
||||
this.element.removeEventListener("mouseleave", this.#clearSegmentHover);
|
||||
}
|
||||
|
||||
get #data() {
|
||||
const totalPieValue = this.segmentsValue.reduce(
|
||||
(acc, s) => acc + Number(s.amount),
|
||||
0,
|
||||
);
|
||||
|
||||
// Overage is always first segment, unused is always last segment
|
||||
return this.segmentsValue
|
||||
.filter((s) => s.amount > 0)
|
||||
.map((s) => ({
|
||||
...s,
|
||||
amount: Math.max(
|
||||
Number(s.amount),
|
||||
totalPieValue * this.#minSegmentAngle,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.id === this.overageSegmentIdValue) return -1;
|
||||
if (b.id === this.overageSegmentIdValue) return 1;
|
||||
if (a.id === this.unusedSegmentIdValue) return 1;
|
||||
if (b.id === this.unusedSegmentIdValue) return -1;
|
||||
return b.amount - a.amount;
|
||||
});
|
||||
}
|
||||
|
||||
#redraw = () => {
|
||||
this.#teardown();
|
||||
this.#draw();
|
||||
};
|
||||
|
||||
#teardown() {
|
||||
d3.select(this.chartContainerTarget).selectAll("*").remove();
|
||||
}
|
||||
|
||||
#draw() {
|
||||
const svg = d3
|
||||
.select(this.chartContainerTarget)
|
||||
.append("svg")
|
||||
.attr("viewBox", `0 0 ${this.#viewBoxSize} ${this.#viewBoxSize}`) // Square aspect ratio
|
||||
.attr("preserveAspectRatio", "xMidYMid meet")
|
||||
.attr("class", "w-full h-full");
|
||||
|
||||
const pie = d3
|
||||
.pie()
|
||||
.sortValues(null) // Preserve order of segments
|
||||
.value((d) => d.amount);
|
||||
|
||||
const mainArc = d3
|
||||
.arc()
|
||||
.innerRadius(this.#viewBoxSize / 2 - this.segmentHeightValue)
|
||||
.outerRadius(this.#viewBoxSize / 2)
|
||||
.cornerRadius(this.segmentHeightValue)
|
||||
.padAngle(this.#minSegmentAngle);
|
||||
|
||||
const segmentArcs = svg
|
||||
.append("g")
|
||||
.attr(
|
||||
"transform",
|
||||
`translate(${this.#viewBoxSize / 2}, ${this.#viewBoxSize / 2})`,
|
||||
)
|
||||
.selectAll("arc")
|
||||
.data(pie(this.#data))
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "arc pointer-events-auto")
|
||||
.append("path")
|
||||
.attr("data-segment-id", (d) => d.data.id)
|
||||
.attr("data-original-color", this.#transformRingColor)
|
||||
.attr("fill", this.#transformRingColor)
|
||||
.attr("d", mainArc);
|
||||
|
||||
// Ensures that user can click on default content without triggering hover on a segment if that is their intent
|
||||
let hoverTimeout = null;
|
||||
|
||||
segmentArcs
|
||||
.on("mouseover", (event) => {
|
||||
hoverTimeout = setTimeout(() => {
|
||||
this.#clearSegmentHover();
|
||||
this.#handleSegmentHover(event);
|
||||
}, 150);
|
||||
})
|
||||
.on("mouseleave", () => {
|
||||
clearTimeout(hoverTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
#transformRingColor = ({ data: { id, color } }) => {
|
||||
if (id === this.unusedSegmentIdValue || id === this.overageSegmentIdValue) {
|
||||
return color;
|
||||
}
|
||||
|
||||
const reducedOpacityColor = d3.color(color);
|
||||
reducedOpacityColor.opacity = this.segmentOpacityValue;
|
||||
return reducedOpacityColor;
|
||||
};
|
||||
|
||||
// Highlights segment and shows segment specific content (all other segments are grayed out)
|
||||
#handleSegmentHover(event) {
|
||||
const segmentId = event.target.dataset.segmentId;
|
||||
const template = this.element.querySelector(`#segment_${segmentId}`);
|
||||
const unusedSegmentId = this.unusedSegmentIdValue;
|
||||
|
||||
if (!template) return;
|
||||
|
||||
d3.select(this.chartContainerTarget)
|
||||
.selectAll("path")
|
||||
.attr("fill", function () {
|
||||
if (this.dataset.segmentId === segmentId) {
|
||||
if (this.dataset.segmentId === unusedSegmentId) {
|
||||
return "#A3A3A3";
|
||||
}
|
||||
|
||||
return this.dataset.originalColor;
|
||||
}
|
||||
|
||||
return "#F0F0F0";
|
||||
});
|
||||
|
||||
this.defaultContentTarget.classList.add("hidden");
|
||||
template.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Restores original segment colors and hides segment specific content
|
||||
#clearSegmentHover = () => {
|
||||
this.defaultContentTarget.classList.remove("hidden");
|
||||
|
||||
d3.select(this.chartContainerTarget)
|
||||
.selectAll("path")
|
||||
.attr("fill", function () {
|
||||
return this.dataset.originalColor;
|
||||
});
|
||||
|
||||
for (const child of this.contentContainerTarget.children) {
|
||||
if (child !== this.defaultContentTarget) {
|
||||
child.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
21
app/javascript/controllers/focus_record_controller.js
Normal file
21
app/javascript/controllers/focus_record_controller.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="focus-record"
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
id: String,
|
||||
};
|
||||
|
||||
connect() {
|
||||
const element = document.getElementById(this.idValue);
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
|
||||
// Remove the focused_record_id parameter from URL
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.delete("focused_record_id");
|
||||
window.history.replaceState({}, "", url);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Controller } from "@hotwired/stimulus";
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
linkToken: String,
|
||||
region: { type: String, default: "us" },
|
||||
};
|
||||
|
||||
open() {
|
||||
@@ -18,7 +19,7 @@ export default class extends Controller {
|
||||
handler.open();
|
||||
}
|
||||
|
||||
handleSuccess(public_token, metadata) {
|
||||
handleSuccess = (public_token, metadata) => {
|
||||
window.location.href = "/accounts";
|
||||
|
||||
fetch("/plaid_items", {
|
||||
@@ -31,6 +32,7 @@ export default class extends Controller {
|
||||
plaid_item: {
|
||||
public_token: public_token,
|
||||
metadata: metadata,
|
||||
region: this.regionValue,
|
||||
},
|
||||
}),
|
||||
}).then((response) => {
|
||||
@@ -38,17 +40,17 @@ export default class extends Controller {
|
||||
window.location.href = response.url;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleExit(err, metadata) {
|
||||
handleExit = (err, metadata) => {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
|
||||
handleEvent(eventName, metadata) {
|
||||
handleEvent = (eventName, metadata) => {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
|
||||
handleLoad() {
|
||||
handleLoad = () => {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
20
app/javascript/controllers/selectable_link_controller.js
Normal file
20
app/javascript/controllers/selectable_link_controller.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="selectable-link"
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.addEventListener("change", this.handleChange.bind(this));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener("change", this.handleChange.bind(this));
|
||||
}
|
||||
|
||||
handleChange(event) {
|
||||
const paramName = this.element.name;
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set(paramName, event.target.value);
|
||||
|
||||
Turbo.visit(currentUrl.toString());
|
||||
}
|
||||
}
|
||||
16
app/javascript/controllers/transfer_match_controller.js
Normal file
16
app/javascript/controllers/transfer_match_controller.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="transfer-match"
|
||||
export default class extends Controller {
|
||||
static targets = ["newSelect", "existingSelect"];
|
||||
|
||||
update(event) {
|
||||
if (event.target.value === "new") {
|
||||
this.newSelectTarget.classList.remove("hidden");
|
||||
this.existingSelectTarget.classList.add("hidden");
|
||||
} else {
|
||||
this.newSelectTarget.classList.add("hidden");
|
||||
this.existingSelectTarget.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
class AutoUpgradeJob < ApplicationJob
|
||||
queue_as :default
|
||||
queue_as :latency_low
|
||||
|
||||
def perform(*args)
|
||||
raise_if_disabled
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class DestroyJob < ApplicationJob
|
||||
queue_as :default
|
||||
queue_as :latency_low
|
||||
|
||||
def perform(model)
|
||||
model.destroy
|
||||
|
||||
7
app/jobs/enrich_data_job.rb
Normal file
7
app/jobs/enrich_data_job.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class EnrichDataJob < ApplicationJob
|
||||
queue_as :latency_high
|
||||
|
||||
def perform(account)
|
||||
account.enrich_data
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class FetchSecurityInfoJob < ApplicationJob
|
||||
queue_as :default
|
||||
queue_as :latency_low
|
||||
|
||||
def perform(security_id)
|
||||
return unless Security.security_info_provider.present?
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class ImportJob < ApplicationJob
|
||||
queue_as :default
|
||||
queue_as :latency_medium
|
||||
|
||||
def perform(import)
|
||||
import.publish
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class SyncJob < ApplicationJob
|
||||
queue_as :default
|
||||
queue_as :latency_medium
|
||||
|
||||
def perform(sync)
|
||||
sync.perform
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class UserPurgeJob < ApplicationJob
|
||||
queue_as :default
|
||||
queue_as :latency_low
|
||||
|
||||
def perform(user)
|
||||
user.purge
|
||||
|
||||
15
app/mailers/email_confirmation_mailer.rb
Normal file
15
app/mailers/email_confirmation_mailer.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
class EmailConfirmationMailer < ApplicationMailer
|
||||
# Subject can be set in your I18n file at config/locales/en.yml
|
||||
# with the following lookup:
|
||||
#
|
||||
# en.email_confirmation_mailer.confirmation_email.subject
|
||||
#
|
||||
def confirmation_email
|
||||
@user = params[:user]
|
||||
@subject = t(".subject")
|
||||
@cta = t(".cta")
|
||||
@confirmation_url = new_email_confirmation_url(token: @user.generate_token_for(:email_confirmation))
|
||||
|
||||
mail to: @user.unconfirmed_email, subject: @subject
|
||||
end
|
||||
end
|
||||
@@ -126,10 +126,20 @@ class Account < ApplicationRecord
|
||||
classification == "asset" ? "up" : "down"
|
||||
end
|
||||
|
||||
def enrich_data
|
||||
DataEnricher.new(self).run
|
||||
end
|
||||
|
||||
def enrich_data_later
|
||||
EnrichDataJob.perform_later(self)
|
||||
end
|
||||
|
||||
def update_with_sync!(attributes)
|
||||
should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance
|
||||
|
||||
transaction do
|
||||
update!(attributes)
|
||||
update_balance!(attributes[:balance]) if attributes[:balance]
|
||||
update_balance!(attributes[:balance]) if should_update_balance
|
||||
end
|
||||
|
||||
sync_later
|
||||
@@ -143,9 +153,68 @@ class Account < ApplicationRecord
|
||||
else
|
||||
entries.create! \
|
||||
date: Date.current,
|
||||
name: "Balance update",
|
||||
amount: balance,
|
||||
currency: currency,
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
end
|
||||
|
||||
def transfer_match_candidates
|
||||
Account::Entry.select([
|
||||
"inflow_candidates.entryable_id as inflow_transaction_id",
|
||||
"outflow_candidates.entryable_id as outflow_transaction_id",
|
||||
"ABS(inflow_candidates.date - outflow_candidates.date) as date_diff"
|
||||
]).from("account_entries inflow_candidates")
|
||||
.joins("
|
||||
JOIN account_entries outflow_candidates ON (
|
||||
inflow_candidates.amount < 0 AND
|
||||
outflow_candidates.amount > 0 AND
|
||||
inflow_candidates.amount = -outflow_candidates.amount AND
|
||||
inflow_candidates.currency = outflow_candidates.currency AND
|
||||
inflow_candidates.account_id <> outflow_candidates.account_id AND
|
||||
inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4
|
||||
)
|
||||
").joins("
|
||||
LEFT JOIN transfers existing_transfers ON (
|
||||
existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id OR
|
||||
existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id
|
||||
)
|
||||
")
|
||||
.joins("LEFT JOIN rejected_transfers ON (
|
||||
rejected_transfers.inflow_transaction_id = inflow_candidates.entryable_id AND
|
||||
rejected_transfers.outflow_transaction_id = outflow_candidates.entryable_id
|
||||
)")
|
||||
.joins("JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id")
|
||||
.joins("JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id")
|
||||
.where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.family_id, self.family_id)
|
||||
.where("inflow_candidates.entryable_type = 'Account::Transaction' AND outflow_candidates.entryable_type = 'Account::Transaction'")
|
||||
.where(existing_transfers: { id: nil })
|
||||
.order("date_diff ASC") # Closest matches first
|
||||
end
|
||||
|
||||
def auto_match_transfers!
|
||||
# Exclude already matched transfers
|
||||
candidates_scope = transfer_match_candidates.where(rejected_transfers: { id: nil })
|
||||
|
||||
# Track which transactions we've already matched to avoid duplicates
|
||||
used_transaction_ids = Set.new
|
||||
|
||||
candidates = []
|
||||
|
||||
Transfer.transaction do
|
||||
candidates_scope.each do |match|
|
||||
next if used_transaction_ids.include?(match.inflow_transaction_id) ||
|
||||
used_transaction_ids.include?(match.outflow_transaction_id)
|
||||
|
||||
Transfer.create!(
|
||||
inflow_transaction_id: match.inflow_transaction_id,
|
||||
outflow_transaction_id: match.outflow_transaction_id,
|
||||
)
|
||||
|
||||
used_transaction_ids << match.inflow_transaction_id
|
||||
used_transaction_ids << match.outflow_transaction_id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,7 +11,7 @@ class Account::BalanceCalculator
|
||||
holdings_value = converted_holdings.select { |h| h.date == balance.date }.sum(&:amount)
|
||||
balance.balance = balance.balance + holdings_value
|
||||
balance
|
||||
end
|
||||
end.compact
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
56
app/models/account/data_enricher.rb
Normal file
56
app/models/account/data_enricher.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
class Account::DataEnricher
|
||||
include Providable
|
||||
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def run
|
||||
enrich_transactions
|
||||
end
|
||||
|
||||
private
|
||||
def enrich_transactions
|
||||
candidates = account.entries.account_transactions.includes(entryable: [ :merchant, :category ])
|
||||
|
||||
Rails.logger.info("Enriching #{candidates.count} transactions for account #{account.id}")
|
||||
|
||||
merchants = {}
|
||||
|
||||
candidates.each do |entry|
|
||||
if entry.enriched_at.nil? || entry.entryable.merchant_id.nil? || entry.entryable.category_id.nil?
|
||||
begin
|
||||
next unless entry.name.present?
|
||||
|
||||
info = self.class.synth_provider.enrich_transaction(entry.name).info
|
||||
|
||||
next unless info.present?
|
||||
|
||||
if info.name.present?
|
||||
merchant = merchants[info.name] ||= account.family.merchants.find_or_create_by(name: info.name)
|
||||
|
||||
if info.icon_url.present?
|
||||
merchant.icon_url = info.icon_url
|
||||
end
|
||||
end
|
||||
|
||||
entryable_attributes = { id: entry.entryable_id }
|
||||
entryable_attributes[:merchant_id] = merchant.id if merchant.present? && entry.entryable.merchant_id.nil?
|
||||
|
||||
Account.transaction do
|
||||
merchant.save! if merchant.present?
|
||||
entry.update!(
|
||||
enriched_at: Time.current,
|
||||
enriched_name: info.name,
|
||||
entryable_attributes: entryable_attributes
|
||||
)
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn("Error enriching transaction #{entry.id}: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,8 @@
|
||||
class Account::Entry < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
Stats = Struct.new(:currency, :count, :income_total, :expense_total, keyword_init: true)
|
||||
|
||||
monetize :amount
|
||||
|
||||
belongs_to :account
|
||||
@@ -10,14 +12,14 @@ class Account::Entry < ApplicationRecord
|
||||
delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy
|
||||
accepts_nested_attributes_for :entryable
|
||||
|
||||
validates :date, :amount, :currency, presence: true
|
||||
validates :date, :name, :amount, :currency, presence: true
|
||||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
|
||||
validates :date, comparison: { greater_than: -> { min_supported_date } }
|
||||
|
||||
scope :chronological, -> {
|
||||
order(
|
||||
date: :asc,
|
||||
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
|
||||
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
|
||||
created_at: :asc
|
||||
)
|
||||
}
|
||||
@@ -25,12 +27,29 @@ class Account::Entry < ApplicationRecord
|
||||
scope :reverse_chronological, -> {
|
||||
order(
|
||||
date: :desc,
|
||||
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
|
||||
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
|
||||
created_at: :desc
|
||||
)
|
||||
}
|
||||
|
||||
scope :without_transfers, -> { where(marked_as_transfer: false) }
|
||||
# All non-transfer entries, rejected transfers, and the outflow of a loan payment transfer are incomes/expenses
|
||||
scope :incomes_and_expenses, -> {
|
||||
joins("INNER JOIN account_transactions ON account_transactions.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
|
||||
.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_transactions.id OR transfers.outflow_transaction_id = account_transactions.id")
|
||||
.joins("LEFT JOIN account_transactions inflow_txns ON inflow_txns.id = transfers.inflow_transaction_id")
|
||||
.joins("LEFT JOIN account_entries inflow_entries ON inflow_entries.entryable_id = inflow_txns.id AND inflow_entries.entryable_type = 'Account::Transaction'")
|
||||
.joins("LEFT JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_entries.account_id")
|
||||
.where("transfers.id IS NULL OR transfers.status = 'rejected' OR (account_entries.amount > 0 AND inflow_accounts.accountable_type = 'Loan')")
|
||||
}
|
||||
|
||||
scope :incomes, -> {
|
||||
incomes_and_expenses.where("account_entries.amount <= 0")
|
||||
}
|
||||
|
||||
scope :expenses, -> {
|
||||
incomes_and_expenses.where("account_entries.amount > 0")
|
||||
}
|
||||
|
||||
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
|
||||
@@ -47,14 +66,6 @@ class Account::Entry < ApplicationRecord
|
||||
account.sync_later(start_date: sync_start_date)
|
||||
end
|
||||
|
||||
def inflow?
|
||||
amount <= 0 && account_transaction?
|
||||
end
|
||||
|
||||
def outflow?
|
||||
amount > 0 && account_transaction?
|
||||
end
|
||||
|
||||
def entryable_name_short
|
||||
entryable_type.demodulize.underscore
|
||||
end
|
||||
@@ -63,7 +74,32 @@ class Account::Entry < ApplicationRecord
|
||||
Account::BalanceTrendCalculator.new(self, entries, balances).trend
|
||||
end
|
||||
|
||||
def display_name
|
||||
enriched_name.presence || name
|
||||
end
|
||||
|
||||
def transfer_match_candidates
|
||||
candidates_scope = account.transfer_match_candidates
|
||||
|
||||
candidates_scope = if amount.negative?
|
||||
candidates_scope.where("inflow_candidates.entryable_id = ?", entryable_id)
|
||||
else
|
||||
candidates_scope.where("outflow_candidates.entryable_id = ?", entryable_id)
|
||||
end
|
||||
|
||||
candidates_scope.map do |pm|
|
||||
Transfer.new(
|
||||
inflow_transaction_id: pm.inflow_transaction_id,
|
||||
outflow_transaction_id: pm.outflow_transaction_id,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def search(params)
|
||||
Account::EntrySearch.new(params).build_query(all)
|
||||
end
|
||||
|
||||
# arbitrary cutoff date to avoid expensive sync operations
|
||||
def min_supported_date
|
||||
30.years.ago.to_date
|
||||
@@ -98,13 +134,6 @@ class Account::Entry < ApplicationRecord
|
||||
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],
|
||||
@@ -127,81 +156,24 @@ class Account::Entry < ApplicationRecord
|
||||
all.size
|
||||
end
|
||||
|
||||
def income_total(currency = "USD")
|
||||
total = without_transfers.account_transactions.includes(:entryable)
|
||||
.where("account_entries.amount <= 0")
|
||||
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
|
||||
.sum
|
||||
def stats(currency = "USD")
|
||||
result = all
|
||||
.incomes_and_expenses
|
||||
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.from_currency AND er.to_currency = ?", currency ]))
|
||||
.select(
|
||||
"COUNT(*) AS count",
|
||||
"SUM(CASE WHEN account_entries.amount < 0 THEN (account_entries.amount * COALESCE(er.rate, 1)) ELSE 0 END) AS income_total",
|
||||
"SUM(CASE WHEN account_entries.amount > 0 THEN (account_entries.amount * COALESCE(er.rate, 1)) ELSE 0 END) AS expense_total"
|
||||
)
|
||||
.to_a
|
||||
.first
|
||||
|
||||
Money.new(total, currency)
|
||||
Stats.new(
|
||||
currency: currency,
|
||||
count: result.count,
|
||||
income_total: result.income_total ? result.income_total * -1 : 0,
|
||||
expense_total: result.expense_total || 0
|
||||
)
|
||||
end
|
||||
|
||||
def expense_total(currency = "USD")
|
||||
total = without_transfers.account_transactions.includes(:entryable)
|
||||
.where("account_entries.amount > 0")
|
||||
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
|
||||
.sum
|
||||
|
||||
Money.new(total, currency)
|
||||
end
|
||||
|
||||
def search(params)
|
||||
query = all
|
||||
query = query.where("account_entries.name ILIKE ?", "%#{sanitize_sql_like(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[:types].present?
|
||||
query = query.where(marked_as_transfer: false) unless params[:types].include?("transfer")
|
||||
|
||||
if params[:types].include?("income") && !params[:types].include?("expense")
|
||||
query = query.where("account_entries.amount < 0")
|
||||
elsif params[:types].include?("expense") && !params[:types].include?("income")
|
||||
query = query.where("account_entries.amount >= 0")
|
||||
end
|
||||
end
|
||||
|
||||
if params[:amount].present? && params[:amount_operator].present?
|
||||
case params[:amount_operator]
|
||||
when "equal"
|
||||
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", params[:amount].to_f.abs)
|
||||
when "less"
|
||||
query = query.where("ABS(account_entries.amount) < ?", params[:amount].to_f.abs)
|
||||
when "greater"
|
||||
query = query.where("ABS(account_entries.amount) > ?", params[:amount].to_f.abs)
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
57
app/models/account/entry_search.rb
Normal file
57
app/models/account/entry_search.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
class Account::EntrySearch
|
||||
include ActiveModel::Model
|
||||
include ActiveModel::Attributes
|
||||
|
||||
attribute :search, :string
|
||||
attribute :amount, :string
|
||||
attribute :amount_operator, :string
|
||||
attribute :types, :string
|
||||
attribute :accounts, array: true
|
||||
attribute :account_ids, array: true
|
||||
attribute :start_date, :string
|
||||
attribute :end_date, :string
|
||||
|
||||
class << self
|
||||
def from_entryable_search(entryable_search)
|
||||
new(entryable_search.attributes.slice(*attribute_names))
|
||||
end
|
||||
end
|
||||
|
||||
def build_query(scope)
|
||||
query = scope
|
||||
|
||||
query = query.where("account_entries.name ILIKE :search OR account_entries.enriched_name ILIKE :search",
|
||||
search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
|
||||
) if search.present?
|
||||
query = query.where("account_entries.date >= ?", start_date) if start_date.present?
|
||||
query = query.where("account_entries.date <= ?", end_date) if end_date.present?
|
||||
|
||||
if types.present?
|
||||
if types.include?("income") && !types.include?("expense")
|
||||
query = query.where("account_entries.amount < 0")
|
||||
elsif types.include?("expense") && !types.include?("income")
|
||||
query = query.where("account_entries.amount >= 0")
|
||||
end
|
||||
end
|
||||
|
||||
if amount.present? && amount_operator.present?
|
||||
case amount_operator
|
||||
when "equal"
|
||||
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", amount.to_f.abs)
|
||||
when "less"
|
||||
query = query.where("ABS(account_entries.amount) < ?", amount.to_f.abs)
|
||||
when "greater"
|
||||
query = query.where("ABS(account_entries.amount) > ?", amount.to_f.abs)
|
||||
end
|
||||
end
|
||||
|
||||
if accounts.present? || account_ids.present?
|
||||
query = query.joins(:account)
|
||||
end
|
||||
|
||||
query = query.where(accounts: { name: accounts }) if accounts.present?
|
||||
query = query.where(accounts: { id: account_ids }) if account_ids.present?
|
||||
|
||||
query
|
||||
end
|
||||
end
|
||||
@@ -48,8 +48,9 @@ class Account::HoldingCalculator
|
||||
def generate_holding_records(portfolio, date)
|
||||
portfolio.map do |security_id, qty|
|
||||
security = securities_cache[security_id]
|
||||
price = security.dig(:prices)&.find { |p| p.date == date }
|
||||
next if security.nil?
|
||||
|
||||
price = security.dig(:prices)&.find { |p| p.date == date }
|
||||
next if price.blank?
|
||||
|
||||
converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount
|
||||
@@ -98,7 +99,7 @@ class Account::HoldingCalculator
|
||||
end
|
||||
|
||||
def trades
|
||||
@trades ||= account.entries.includes(entryable: :security).account_trades.to_a
|
||||
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
|
||||
end
|
||||
|
||||
def portfolio_start_date
|
||||
@@ -106,7 +107,10 @@ class Account::HoldingCalculator
|
||||
end
|
||||
|
||||
def preload_securities
|
||||
# Get securities from trades and current holdings
|
||||
securities = trades.map(&:entryable).map(&:security).uniq
|
||||
securities += account.holdings.where(date: Date.current).map(&:security)
|
||||
securities.uniq!
|
||||
|
||||
securities.each do |security|
|
||||
prices = Security::Price.find_prices(
|
||||
|
||||
@@ -5,11 +5,20 @@ class Account::Syncer
|
||||
end
|
||||
|
||||
def run
|
||||
account.auto_match_transfers!
|
||||
|
||||
holdings = sync_holdings
|
||||
balances = sync_balances(holdings)
|
||||
account.reload
|
||||
update_account_info(balances, holdings) unless account.plaid_account_id.present?
|
||||
convert_records_to_family_currency(balances, holdings) unless account.currency == account.family.currency
|
||||
|
||||
# Enrich if user opted in or if we're syncing transactions from a Plaid account on the hosted app
|
||||
if account.family.data_enrichment_enabled? || (account.plaid_account_id.present? && Rails.application.config.app_mode.hosted?)
|
||||
account.enrich_data_later
|
||||
else
|
||||
Rails.logger.info("Data enrichment is disabled, skipping enrichment for account #{account.id}")
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
@@ -67,29 +76,33 @@ class Account::Syncer
|
||||
exchange_rates = ExchangeRate.find_rates(
|
||||
from: from_currency,
|
||||
to: to_currency,
|
||||
start_date: balances.first.date
|
||||
start_date: balances.min_by(&:date).date
|
||||
)
|
||||
|
||||
converted_balances = balances.map do |balance|
|
||||
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
|
||||
|
||||
next unless exchange_rate.present?
|
||||
|
||||
account.balances.build(
|
||||
date: balance.date,
|
||||
balance: exchange_rate.rate * balance.balance,
|
||||
currency: to_currency
|
||||
) if exchange_rate.present?
|
||||
end
|
||||
)
|
||||
end.compact
|
||||
|
||||
converted_holdings = holdings.map do |holding|
|
||||
exchange_rate = exchange_rates.find { |er| er.date == holding.date }
|
||||
|
||||
next unless exchange_rate.present?
|
||||
|
||||
account.holdings.build(
|
||||
security: holding.security,
|
||||
date: holding.date,
|
||||
amount: exchange_rate.rate * holding.amount,
|
||||
currency: to_currency
|
||||
) if exchange_rate.present?
|
||||
end
|
||||
)
|
||||
end.compact
|
||||
|
||||
Account.transaction do
|
||||
load_balances(converted_balances)
|
||||
|
||||
@@ -8,31 +8,8 @@ class Account::Trade < ApplicationRecord
|
||||
validates :qty, presence: true
|
||||
validates :price, :currency, presence: true
|
||||
|
||||
class << self
|
||||
def search(_params)
|
||||
all
|
||||
end
|
||||
|
||||
def requires_search?(_params)
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def sell?
|
||||
qty < 0
|
||||
end
|
||||
|
||||
def buy?
|
||||
qty > 0
|
||||
end
|
||||
|
||||
def name
|
||||
prefix = sell? ? "Sell " : "Buy "
|
||||
prefix + "#{qty.abs} shares of #{security.ticker}"
|
||||
end
|
||||
|
||||
def unrealized_gain_loss
|
||||
return nil if sell?
|
||||
return nil if qty.negative?
|
||||
current_price = security.current_price
|
||||
return nil if current_price.nil?
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ class Account::TradeBuilder
|
||||
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||
:price, :ticker, :type, :transfer_account_id
|
||||
|
||||
attr_reader :buildable
|
||||
|
||||
def initialize(attributes = {})
|
||||
super
|
||||
@buildable = set_buildable
|
||||
end
|
||||
|
||||
def save
|
||||
buildable.save
|
||||
end
|
||||
@@ -17,7 +24,7 @@ class Account::TradeBuilder
|
||||
end
|
||||
|
||||
private
|
||||
def buildable
|
||||
def set_buildable
|
||||
case type
|
||||
when "buy", "sell"
|
||||
build_trade
|
||||
@@ -31,7 +38,11 @@ class Account::TradeBuilder
|
||||
end
|
||||
|
||||
def build_trade
|
||||
prefix = type == "sell" ? "Sell " : "Buy "
|
||||
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
|
||||
|
||||
account.entries.new(
|
||||
name: trade_name,
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
@@ -51,9 +62,9 @@ class Account::TradeBuilder
|
||||
from_account = type == "withdrawal" ? account : transfer_account
|
||||
to_account = type == "withdrawal" ? transfer_account : account
|
||||
|
||||
Account::Transfer.build_from_accounts(
|
||||
from_account,
|
||||
to_account,
|
||||
Transfer.from_accounts(
|
||||
from_account: from_account,
|
||||
to_account: to_account,
|
||||
date: date,
|
||||
amount: signed_amount
|
||||
)
|
||||
@@ -63,7 +74,6 @@ class Account::TradeBuilder
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
)
|
||||
end
|
||||
|
||||
@@ -6,62 +6,28 @@ class Account::Transaction < ApplicationRecord
|
||||
has_many :taggings, as: :taggable, dependent: :destroy
|
||||
has_many :tags, through: :taggings
|
||||
|
||||
has_one :transfer_as_inflow, class_name: "Transfer", foreign_key: "inflow_transaction_id", dependent: :destroy
|
||||
has_one :transfer_as_outflow, class_name: "Transfer", foreign_key: "outflow_transaction_id", dependent: :destroy
|
||||
|
||||
# We keep track of rejected transfers to avoid auto-matching them again
|
||||
has_one :rejected_transfer_as_inflow, class_name: "RejectedTransfer", foreign_key: "inflow_transaction_id", dependent: :destroy
|
||||
has_one :rejected_transfer_as_outflow, class_name: "RejectedTransfer", foreign_key: "outflow_transaction_id", dependent: :destroy
|
||||
|
||||
accepts_nested_attributes_for :taggings, allow_destroy: true
|
||||
|
||||
scope :active, -> { where(excluded: false) }
|
||||
|
||||
class << self
|
||||
def search(params)
|
||||
query = all
|
||||
if params[:categories].present?
|
||||
if params[:categories].exclude?("Uncategorized")
|
||||
query = query
|
||||
.joins(:category)
|
||||
.where(categories: { name: params[:categories] })
|
||||
else
|
||||
query = query
|
||||
.left_joins(:category)
|
||||
.where(categories: { name: params[:categories] })
|
||||
.or(query.where(category_id: nil))
|
||||
end
|
||||
end
|
||||
|
||||
query = query.joins(:merchant).where(merchants: { name: params[:merchants] }) if params[:merchants].present?
|
||||
|
||||
if params[:tags].present?
|
||||
query = query.joins(:tags)
|
||||
.where(tags: { name: params[:tags] })
|
||||
.distinct
|
||||
end
|
||||
|
||||
query
|
||||
Account::TransactionSearch.new(params).build_query(all)
|
||||
end
|
||||
|
||||
def requires_search?(params)
|
||||
searchable_keys.any? { |key| params.key?(key) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def searchable_keys
|
||||
%i[categories merchants tags]
|
||||
end
|
||||
end
|
||||
|
||||
def name
|
||||
entry.name || "(no description)"
|
||||
def transfer
|
||||
transfer_as_inflow || transfer_as_outflow
|
||||
end
|
||||
|
||||
def eod_balance
|
||||
entry.amount_money
|
||||
def transfer?
|
||||
transfer.present?
|
||||
end
|
||||
|
||||
private
|
||||
def account
|
||||
entry.account
|
||||
end
|
||||
|
||||
def daily_transactions
|
||||
account.entries.account_transactions
|
||||
end
|
||||
end
|
||||
|
||||
47
app/models/account/transaction_search.rb
Normal file
47
app/models/account/transaction_search.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
class Account::TransactionSearch
|
||||
include ActiveModel::Model
|
||||
include ActiveModel::Attributes
|
||||
|
||||
attribute :search, :string
|
||||
attribute :amount, :string
|
||||
attribute :amount_operator, :string
|
||||
attribute :types, array: true
|
||||
attribute :accounts, array: true
|
||||
attribute :account_ids, array: true
|
||||
attribute :start_date, :string
|
||||
attribute :end_date, :string
|
||||
attribute :categories, array: true
|
||||
attribute :merchants, array: true
|
||||
attribute :tags, array: true
|
||||
|
||||
# Returns array of Account::Entry objects to stay consistent with partials, which only deal with Account::Entry
|
||||
def build_query(scope)
|
||||
query = scope
|
||||
|
||||
if types.present? && types.exclude?("transfer")
|
||||
query = query.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_entries.id OR transfers.outflow_transaction_id = account_entries.id")
|
||||
.where("transfers.id IS NULL")
|
||||
end
|
||||
|
||||
if categories.present?
|
||||
if categories.exclude?("Uncategorized")
|
||||
query = query
|
||||
.joins(:category)
|
||||
.where(categories: { name: categories })
|
||||
else
|
||||
query = query
|
||||
.left_joins(:category)
|
||||
.where(categories: { name: categories })
|
||||
.or(query.where(category_id: nil))
|
||||
end
|
||||
end
|
||||
|
||||
query = query.joins(:merchant).where(merchants: { name: merchants }) if merchants.present?
|
||||
|
||||
query = query.joins(:tags).where(tags: { name: tags }) if tags.present?
|
||||
|
||||
entries_scope = Account::Entry.account_transactions.where(entryable_id: query.select(:id))
|
||||
|
||||
Account::EntrySearch.from_entryable_search(self).build_query(entries_scope)
|
||||
end
|
||||
end
|
||||
@@ -1,113 +0,0 @@
|
||||
class Account::Transfer < ApplicationRecord
|
||||
has_many :entries, dependent: :destroy
|
||||
|
||||
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 || Money.new(0)
|
||||
end
|
||||
|
||||
def from_name
|
||||
from_account&.name || I18n.t("account/transfer.from_fallback_name")
|
||||
end
|
||||
|
||||
def to_name
|
||||
to_account&.name || I18n.t("account/transfer.to_fallback_name")
|
||||
end
|
||||
|
||||
def name
|
||||
I18n.t("account/transfer.name", from_account: from_name, to_account: to_name)
|
||||
end
|
||||
|
||||
def from_account
|
||||
outflow_transaction&.account
|
||||
end
|
||||
|
||||
def to_account
|
||||
inflow_transaction&.account
|
||||
end
|
||||
|
||||
def inflow_transaction
|
||||
entries.find { |e| e.inflow? }
|
||||
end
|
||||
|
||||
def outflow_transaction
|
||||
entries.find { |e| e.outflow? }
|
||||
end
|
||||
|
||||
def update_entries!(params)
|
||||
transaction do
|
||||
entries.each do |entry|
|
||||
entry.update!(params)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
entries.each(&:sync_account_later)
|
||||
end
|
||||
|
||||
class << self
|
||||
def build_from_accounts(from_account, to_account, date:, amount:)
|
||||
outflow = from_account.entries.build \
|
||||
amount: amount.abs,
|
||||
currency: from_account.currency,
|
||||
date: date,
|
||||
name: "Transfer to #{to_account.name}",
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
# Attempt to convert the amount to the to_account's currency. If the conversion fails,
|
||||
# use the original amount.
|
||||
converted_amount = begin
|
||||
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
|
||||
rescue Money::ConversionError
|
||||
Money.new(amount.abs, from_account.currency)
|
||||
end
|
||||
|
||||
inflow = to_account.entries.build \
|
||||
amount: converted_amount.amount * -1,
|
||||
currency: converted_amount.currency.iso_code,
|
||||
date: date,
|
||||
name: "Transfer from #{from_account.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 :entries, :must_have_an_inflow_and_outflow_that_net_to_zero
|
||||
end
|
||||
end
|
||||
|
||||
def all_transactions_marked
|
||||
unless entries.all?(&:marked_as_transfer)
|
||||
errors.add :entries, :must_be_marked_as_transfer
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,13 +1,3 @@
|
||||
class Account::Valuation < ApplicationRecord
|
||||
include Account::Entryable
|
||||
|
||||
class << self
|
||||
def search(_params)
|
||||
all
|
||||
end
|
||||
|
||||
def requires_search?(_params)
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
182
app/models/budget.rb
Normal file
182
app/models/budget.rb
Normal file
@@ -0,0 +1,182 @@
|
||||
class Budget < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
belongs_to :family
|
||||
|
||||
has_many :budget_categories, dependent: :destroy
|
||||
|
||||
validates :start_date, :end_date, presence: true
|
||||
validates :start_date, :end_date, uniqueness: { scope: :family_id }
|
||||
|
||||
monetize :budgeted_spending, :expected_income, :allocated_spending,
|
||||
:actual_spending, :available_to_spend, :available_to_allocate,
|
||||
:estimated_spending, :estimated_income, :actual_income, :remaining_expected_income
|
||||
|
||||
class << self
|
||||
def for_date(date)
|
||||
find_by(start_date: date.beginning_of_month, end_date: date.end_of_month)
|
||||
end
|
||||
|
||||
def find_or_bootstrap(family, date: Date.current)
|
||||
Budget.transaction do
|
||||
budget = Budget.find_or_create_by!(
|
||||
family: family,
|
||||
start_date: date.beginning_of_month,
|
||||
end_date: date.end_of_month
|
||||
) do |b|
|
||||
b.currency = family.currency
|
||||
end
|
||||
|
||||
budget.sync_budget_categories
|
||||
|
||||
budget
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_budget_categories
|
||||
family.categories.expenses.each do |category|
|
||||
budget_categories.find_or_create_by(
|
||||
category: category,
|
||||
) do |bc|
|
||||
bc.budgeted_spending = 0
|
||||
bc.currency = family.currency
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def uncategorized_budget_category
|
||||
budget_categories.uncategorized.tap do |bc|
|
||||
bc.budgeted_spending = [ available_to_allocate, 0 ].max
|
||||
bc.currency = family.currency
|
||||
end
|
||||
end
|
||||
|
||||
def entries
|
||||
family.entries.incomes_and_expenses.where(date: start_date..end_date)
|
||||
end
|
||||
|
||||
def name
|
||||
start_date.strftime("%B %Y")
|
||||
end
|
||||
|
||||
def initialized?
|
||||
budgeted_spending.present?
|
||||
end
|
||||
|
||||
def income_categories_with_totals
|
||||
family.income_categories_with_totals(date: start_date)
|
||||
end
|
||||
|
||||
def expense_categories_with_totals
|
||||
family.expense_categories_with_totals(date: start_date)
|
||||
end
|
||||
|
||||
def current?
|
||||
start_date == Date.today.beginning_of_month && end_date == Date.today.end_of_month
|
||||
end
|
||||
|
||||
def previous_budget
|
||||
prev_month_end_date = end_date - 1.month
|
||||
return nil if prev_month_end_date < family.oldest_entry_date
|
||||
family.budgets.find_or_bootstrap(family, date: prev_month_end_date)
|
||||
end
|
||||
|
||||
def next_budget
|
||||
return nil if current?
|
||||
next_start_date = start_date + 1.month
|
||||
family.budgets.find_or_bootstrap(family, date: next_start_date)
|
||||
end
|
||||
|
||||
def to_donut_segments_json
|
||||
unused_segment_id = "unused"
|
||||
|
||||
# Continuous gray segment for empty budgets
|
||||
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless allocations_valid?
|
||||
|
||||
segments = budget_categories.includes(:category).map do |bc|
|
||||
{ color: bc.category.color, amount: bc.actual_spending, id: bc.id }
|
||||
end
|
||||
|
||||
if available_to_spend.positive?
|
||||
segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id })
|
||||
end
|
||||
|
||||
segments
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Actuals: How much user has spent on each budget category
|
||||
# =============================================================================
|
||||
def estimated_spending
|
||||
family.budgeting_stats.avg_monthly_expenses&.abs
|
||||
end
|
||||
|
||||
def actual_spending
|
||||
expense_categories_with_totals.total_money.amount
|
||||
end
|
||||
|
||||
def available_to_spend
|
||||
(budgeted_spending || 0) - actual_spending
|
||||
end
|
||||
|
||||
def percent_of_budget_spent
|
||||
return 0 unless budgeted_spending > 0
|
||||
|
||||
(actual_spending / budgeted_spending.to_f) * 100
|
||||
end
|
||||
|
||||
def overage_percent
|
||||
return 0 unless available_to_spend.negative?
|
||||
|
||||
available_to_spend.abs / actual_spending.to_f * 100
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Budget allocations: How much user has budgeted for all categories combined
|
||||
# =============================================================================
|
||||
def allocated_spending
|
||||
budget_categories.sum(:budgeted_spending)
|
||||
end
|
||||
|
||||
def allocated_percent
|
||||
return 0 unless budgeted_spending && budgeted_spending > 0
|
||||
|
||||
(allocated_spending / budgeted_spending.to_f) * 100
|
||||
end
|
||||
|
||||
def available_to_allocate
|
||||
(budgeted_spending || 0) - allocated_spending
|
||||
end
|
||||
|
||||
def allocations_valid?
|
||||
initialized? && available_to_allocate.positive? && allocated_spending > 0
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Income: How much user earned relative to what they expected to earn
|
||||
# =============================================================================
|
||||
def estimated_income
|
||||
family.budgeting_stats.avg_monthly_income&.abs
|
||||
end
|
||||
|
||||
def actual_income
|
||||
family.entries.incomes.where(date: start_date..end_date).sum(:amount).abs
|
||||
end
|
||||
|
||||
def actual_income_percent
|
||||
return 0 unless expected_income > 0
|
||||
|
||||
(actual_income / expected_income.to_f) * 100
|
||||
end
|
||||
|
||||
def remaining_expected_income
|
||||
expected_income - actual_income
|
||||
end
|
||||
|
||||
def surplus_percent
|
||||
return 0 unless remaining_expected_income.negative?
|
||||
|
||||
remaining_expected_income.abs / expected_income.to_f * 100
|
||||
end
|
||||
end
|
||||
82
app/models/budget_category.rb
Normal file
82
app/models/budget_category.rb
Normal file
@@ -0,0 +1,82 @@
|
||||
class BudgetCategory < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
belongs_to :budget
|
||||
belongs_to :category
|
||||
|
||||
validates :budget_id, uniqueness: { scope: :category_id }
|
||||
|
||||
monetize :budgeted_spending, :actual_spending, :available_to_spend
|
||||
|
||||
class Group
|
||||
attr_reader :budget_category, :budget_subcategories
|
||||
|
||||
delegate :category, to: :budget_category
|
||||
delegate :name, :color, to: :category
|
||||
|
||||
def self.for(budget_categories)
|
||||
top_level_categories = budget_categories.select { |budget_category| budget_category.category.parent_id.nil? }
|
||||
top_level_categories.map do |top_level_category|
|
||||
subcategories = budget_categories.select { |bc| bc.category.parent_id == top_level_category.category_id && top_level_category.category_id.present? }
|
||||
new(top_level_category, subcategories.sort_by { |subcategory| subcategory.category.name })
|
||||
end.sort_by { |group| group.category.name }
|
||||
end
|
||||
|
||||
def initialize(budget_category, budget_subcategories = [])
|
||||
@budget_category = budget_category
|
||||
@budget_subcategories = budget_subcategories
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def uncategorized
|
||||
new(
|
||||
id: Digest::UUID.uuid_v5(Digest::UUID::URL_NAMESPACE, "uncategorized"),
|
||||
category: nil,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def initialized?
|
||||
budget.initialized?
|
||||
end
|
||||
|
||||
def category
|
||||
super || budget.family.categories.uncategorized
|
||||
end
|
||||
|
||||
def subcategory?
|
||||
category.parent_id.present?
|
||||
end
|
||||
|
||||
def actual_spending
|
||||
category.month_total(date: budget.start_date)
|
||||
end
|
||||
|
||||
def available_to_spend
|
||||
(budgeted_spending || 0) - actual_spending
|
||||
end
|
||||
|
||||
def percent_of_budget_spent
|
||||
return 0 unless budgeted_spending > 0
|
||||
|
||||
(actual_spending / budgeted_spending) * 100
|
||||
end
|
||||
|
||||
def to_donut_segments_json
|
||||
unused_segment_id = "unused"
|
||||
overage_segment_id = "overage"
|
||||
|
||||
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless actual_spending > 0
|
||||
|
||||
segments = [ { color: category.color, amount: actual_spending, id: id } ]
|
||||
|
||||
if available_to_spend.negative?
|
||||
segments.push({ color: "#EF4444", amount: available_to_spend.abs, id: overage_segment_id })
|
||||
else
|
||||
segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id })
|
||||
end
|
||||
|
||||
segments
|
||||
end
|
||||
end
|
||||
29
app/models/budgeting_stats.rb
Normal file
29
app/models/budgeting_stats.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
class BudgetingStats
|
||||
attr_reader :family
|
||||
|
||||
def initialize(family)
|
||||
@family = family
|
||||
end
|
||||
|
||||
def avg_monthly_income
|
||||
income_expense_totals_query(Account::Entry.incomes)
|
||||
end
|
||||
|
||||
def avg_monthly_expenses
|
||||
income_expense_totals_query(Account::Entry.expenses)
|
||||
end
|
||||
|
||||
private
|
||||
def income_expense_totals_query(type_scope)
|
||||
monthly_totals = family.entries
|
||||
.merge(type_scope)
|
||||
.select("SUM(account_entries.amount) as total")
|
||||
.group(Arel.sql("date_trunc('month', account_entries.date)"))
|
||||
|
||||
result = Family.select("AVG(mt.total)")
|
||||
.from(monthly_totals, :mt)
|
||||
.pick("AVG(mt.total)")
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
@@ -1,43 +1,96 @@
|
||||
class Category < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
||||
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||
|
||||
belongs_to :family
|
||||
|
||||
has_many :budget_categories, dependent: :destroy
|
||||
has_many :subcategories, class_name: "Category", foreign_key: :parent_id
|
||||
belongs_to :parent, class_name: "Category", optional: true
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
validates :name, uniqueness: { scope: :family_id }
|
||||
|
||||
before_update :clear_internal_category, if: :name_changed?
|
||||
validate :category_level_limit
|
||||
validate :nested_category_matches_parent_classification
|
||||
|
||||
before_create :inherit_color_from_parent
|
||||
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
scope :roots, -> { where(parent_id: nil) }
|
||||
scope :incomes, -> { where(classification: "income") }
|
||||
scope :expenses, -> { where(classification: "expense") }
|
||||
|
||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
|
||||
UNCATEGORIZED_COLOR = "#737373"
|
||||
TRANSFER_COLOR = "#444CE7"
|
||||
PAYMENT_COLOR = "#db5a54"
|
||||
TRADE_COLOR = "#e99537"
|
||||
|
||||
DEFAULT_CATEGORIES = [
|
||||
{ internal_category: "income", color: COLORS[0] },
|
||||
{ internal_category: "food_and_drink", color: COLORS[1] },
|
||||
{ internal_category: "entertainment", color: COLORS[2] },
|
||||
{ internal_category: "personal_care", color: COLORS[3] },
|
||||
{ internal_category: "general_services", color: COLORS[4] },
|
||||
{ internal_category: "auto_and_transport", color: COLORS[5] },
|
||||
{ internal_category: "rent_and_utilities", color: COLORS[6] },
|
||||
{ internal_category: "home_improvement", color: COLORS[7] }
|
||||
]
|
||||
class Group
|
||||
attr_reader :category, :subcategories
|
||||
|
||||
def self.create_default_categories(family)
|
||||
if family.categories.size > 0
|
||||
raise ArgumentError, "Family already has some categories"
|
||||
delegate :name, :color, to: :category
|
||||
|
||||
def self.for(categories)
|
||||
categories.select { |category| category.parent_id.nil? }.map do |category|
|
||||
new(category, category.subcategories)
|
||||
end
|
||||
end
|
||||
|
||||
family_id = family.id
|
||||
categories = self::DEFAULT_CATEGORIES.map { |c| {
|
||||
name: I18n.t("transaction.default_category.#{c[:internal_category]}"),
|
||||
internal_category: c[:internal_category],
|
||||
color: c[:color],
|
||||
family_id:
|
||||
} }
|
||||
self.insert_all(categories)
|
||||
def initialize(category, subcategories = nil)
|
||||
@category = category
|
||||
@subcategories = subcategories || []
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def icon_codes
|
||||
%w[bus circle-dollar-sign ambulance apple award baby battery lightbulb bed-single beer bluetooth book briefcase building credit-card camera utensils cooking-pot cookie dices drama dog drill drum dumbbell gamepad-2 graduation-cap house hand-helping ice-cream-cone phone piggy-bank pill pizza printer puzzle ribbon shopping-cart shield-plus ticket trees]
|
||||
end
|
||||
|
||||
def bootstrap_defaults
|
||||
default_categories.each do |name, color, icon|
|
||||
find_or_create_by!(name: name) do |category|
|
||||
category.color = color
|
||||
category.classification = "income" if name == "Income"
|
||||
category.lucide_icon = icon
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def uncategorized
|
||||
new(
|
||||
name: "Uncategorized",
|
||||
color: UNCATEGORIZED_COLOR,
|
||||
lucide_icon: "circle-dashed"
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def default_categories
|
||||
[
|
||||
[ "Income", "#e99537", "circle-dollar-sign" ],
|
||||
[ "Housing", "#6471eb", "house" ],
|
||||
[ "Entertainment", "#df4e92", "drama" ],
|
||||
[ "Food & Drink", "#eb5429", "utensils" ],
|
||||
[ "Shopping", "#e99537", "shopping-cart" ],
|
||||
[ "Healthcare", "#4da568", "pill" ],
|
||||
[ "Insurance", "#6471eb", "piggy-bank" ],
|
||||
[ "Utilities", "#db5a54", "lightbulb" ],
|
||||
[ "Transportation", "#df4e92", "bus" ],
|
||||
[ "Education", "#eb5429", "book" ],
|
||||
[ "Gifts & Donations", "#61c9ea", "hand-helping" ],
|
||||
[ "Subscriptions", "#805dee", "credit-card" ]
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
def inherit_color_from_parent
|
||||
if subcategory?
|
||||
self.color = parent.color
|
||||
end
|
||||
end
|
||||
|
||||
def replace_and_destroy!(replacement)
|
||||
@@ -47,9 +100,48 @@ class Category < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def parent?
|
||||
subcategories.any?
|
||||
end
|
||||
|
||||
def clear_internal_category
|
||||
self.internal_category = nil
|
||||
def subcategory?
|
||||
parent.present?
|
||||
end
|
||||
|
||||
def avg_monthly_total
|
||||
family.category_stats.avg_monthly_total_for(self)
|
||||
end
|
||||
|
||||
def median_monthly_total
|
||||
family.category_stats.median_monthly_total_for(self)
|
||||
end
|
||||
|
||||
def month_total(date: Date.current)
|
||||
family.category_stats.month_total_for(self, date: date)
|
||||
end
|
||||
|
||||
def avg_monthly_total_money
|
||||
Money.new(avg_monthly_total, family.currency)
|
||||
end
|
||||
|
||||
def median_monthly_total_money
|
||||
Money.new(median_monthly_total, family.currency)
|
||||
end
|
||||
|
||||
def month_total_money(date: Date.current)
|
||||
Money.new(month_total(date: date), family.currency)
|
||||
end
|
||||
|
||||
private
|
||||
def category_level_limit
|
||||
if (subcategory? && parent.subcategory?) || (parent? && subcategory?)
|
||||
errors.add(:parent, "can't have more than 2 levels of subcategories")
|
||||
end
|
||||
end
|
||||
|
||||
def nested_category_matches_parent_classification
|
||||
if subcategory? && parent.classification != classification
|
||||
errors.add(:parent, "must have the same classification as its parent")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
179
app/models/category_stats.rb
Normal file
179
app/models/category_stats.rb
Normal file
@@ -0,0 +1,179 @@
|
||||
class CategoryStats
|
||||
attr_reader :family
|
||||
|
||||
def initialize(family)
|
||||
@family = family
|
||||
end
|
||||
|
||||
def avg_monthly_total_for(category)
|
||||
statistics_data[category.id]&.avg || 0
|
||||
end
|
||||
|
||||
def median_monthly_total_for(category)
|
||||
statistics_data[category.id]&.median || 0
|
||||
end
|
||||
|
||||
def month_total_for(category, date: Date.current)
|
||||
monthly_totals = totals_data[category.id]
|
||||
|
||||
category_total = monthly_totals&.find { |mt| mt.month == date.month && mt.year == date.year }
|
||||
|
||||
category_total&.amount || 0
|
||||
end
|
||||
|
||||
def month_category_totals(date: Date.current)
|
||||
by_classification = Hash.new { |h, k| h[k] = {} }
|
||||
|
||||
totals_data.each_with_object(by_classification) do |(category_id, totals), result|
|
||||
totals.each do |t|
|
||||
next unless t.month == date.month && t.year == date.year
|
||||
result[t.classification][category_id] ||= { amount: 0, subcategory: t.subcategory? }
|
||||
result[t.classification][category_id][:amount] += t.amount.abs
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate percentages for each group
|
||||
category_totals = []
|
||||
|
||||
[ "income", "expense" ].each do |classification|
|
||||
totals = by_classification[classification]
|
||||
|
||||
# Only include non-subcategory amounts in the total for percentage calculations
|
||||
total_amount = totals.sum do |_, data|
|
||||
data[:subcategory] ? 0 : data[:amount]
|
||||
end
|
||||
|
||||
next if total_amount.zero?
|
||||
|
||||
totals.each do |category_id, data|
|
||||
percentage = (data[:amount].to_f / total_amount * 100).round(1)
|
||||
|
||||
category_totals << CategoryTotal.new(
|
||||
category_id: category_id,
|
||||
amount: data[:amount],
|
||||
percentage: percentage,
|
||||
classification: classification,
|
||||
currency: family.currency,
|
||||
subcategory?: data[:subcategory]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate totals based on non-subcategory amounts only
|
||||
total_income = category_totals
|
||||
.select { |ct| ct.classification == "income" && !ct.subcategory? }
|
||||
.sum(&:amount)
|
||||
|
||||
total_expense = category_totals
|
||||
.select { |ct| ct.classification == "expense" && !ct.subcategory? }
|
||||
.sum(&:amount)
|
||||
|
||||
CategoryTotals.new(
|
||||
total_income: total_income,
|
||||
total_expense: total_expense,
|
||||
category_totals: category_totals
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
Totals = Struct.new(:month, :year, :amount, :classification, :currency, :subcategory?, keyword_init: true)
|
||||
Stats = Struct.new(:avg, :median, :currency, keyword_init: true)
|
||||
CategoryTotals = Struct.new(:total_income, :total_expense, :category_totals, keyword_init: true)
|
||||
CategoryTotal = Struct.new(:category_id, :amount, :percentage, :classification, :currency, :subcategory?, keyword_init: true)
|
||||
|
||||
def statistics_data
|
||||
@statistics_data ||= begin
|
||||
stats = totals_data.each_with_object({ nil => Stats.new(avg: 0, median: 0) }) do |(category_id, totals), hash|
|
||||
next if totals.empty?
|
||||
|
||||
amounts = totals.map(&:amount)
|
||||
hash[category_id] = Stats.new(
|
||||
avg: (amounts.sum.to_f / amounts.size).round,
|
||||
median: calculate_median(amounts),
|
||||
currency: family.currency
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def totals_data
|
||||
@totals_data ||= begin
|
||||
totals = monthly_totals_query.each_with_object({ nil => [] }) do |row, hash|
|
||||
hash[row.category_id] ||= []
|
||||
existing_total = hash[row.category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
|
||||
|
||||
if existing_total
|
||||
existing_total.amount += row.total.to_i
|
||||
else
|
||||
hash[row.category_id] << Totals.new(
|
||||
month: row.date.month,
|
||||
year: row.date.year,
|
||||
amount: row.total.to_i,
|
||||
classification: row.classification,
|
||||
currency: family.currency,
|
||||
subcategory?: row.parent_category_id.present?
|
||||
)
|
||||
end
|
||||
|
||||
# If category is a parent, its total includes its own transactions + sum(child category transactions)
|
||||
if row.parent_category_id
|
||||
hash[row.parent_category_id] ||= []
|
||||
|
||||
existing_parent_total = hash[row.parent_category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
|
||||
|
||||
if existing_parent_total
|
||||
existing_parent_total.amount += row.total.to_i
|
||||
else
|
||||
hash[row.parent_category_id] << Totals.new(
|
||||
month: row.date.month,
|
||||
year: row.date.year,
|
||||
amount: row.total.to_i,
|
||||
classification: row.classification,
|
||||
currency: family.currency,
|
||||
subcategory?: false
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Ensure we have a default empty array for nil category, which represents "Uncategorized"
|
||||
totals[nil] ||= []
|
||||
totals
|
||||
end
|
||||
end
|
||||
|
||||
def monthly_totals_query
|
||||
income_expense_classification = Arel.sql("
|
||||
CASE WHEN categories.id IS NULL THEN
|
||||
CASE WHEN account_entries.amount < 0 THEN 'income' ELSE 'expense' END
|
||||
ELSE categories.classification
|
||||
END
|
||||
")
|
||||
|
||||
family.entries
|
||||
.incomes_and_expenses
|
||||
.select(
|
||||
"categories.id as category_id",
|
||||
"categories.parent_id as parent_category_id",
|
||||
income_expense_classification,
|
||||
"date_trunc('month', account_entries.date) as date",
|
||||
"SUM(account_entries.amount) as total"
|
||||
)
|
||||
.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
|
||||
.group(Arel.sql("categories.id, categories.parent_id, #{income_expense_classification}, date_trunc('month', account_entries.date)"))
|
||||
.order(Arel.sql("date_trunc('month', account_entries.date) DESC"))
|
||||
end
|
||||
|
||||
|
||||
def calculate_median(numbers)
|
||||
return 0 if numbers.empty?
|
||||
|
||||
sorted = numbers.sort
|
||||
mid = sorted.size / 2
|
||||
if sorted.size.odd?
|
||||
sorted[mid]
|
||||
else
|
||||
((sorted[mid-1] + sorted[mid]) / 2.0).round
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,13 +2,25 @@ module Plaidable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def plaid_provider
|
||||
Provider::Plaid.new if Rails.application.config.plaid
|
||||
def plaid_us_provider
|
||||
Provider::Plaid.new(Rails.application.config.plaid, :us) if Rails.application.config.plaid
|
||||
end
|
||||
|
||||
def plaid_eu_provider
|
||||
Provider::Plaid.new(Rails.application.config.plaid_eu, :eu) if Rails.application.config.plaid_eu
|
||||
end
|
||||
|
||||
def plaid_provider_for_region(region)
|
||||
region.to_sym == :eu ? plaid_eu_provider : plaid_us_provider
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def eu?
|
||||
raise "eu? is not implemented for #{self.class.name}"
|
||||
end
|
||||
|
||||
def plaid_provider
|
||||
self.class.plaid_provider
|
||||
eu? ? self.class.plaid_eu_provider : self.class.plaid_us_provider
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,98 +1,124 @@
|
||||
class Demo::Generator
|
||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
|
||||
def initialize
|
||||
@family = reset_family!
|
||||
end
|
||||
# Builds a semi-realistic mirror of what production data might look like
|
||||
def reset_and_clear_data!(family_names)
|
||||
puts "Clearing existing data..."
|
||||
|
||||
def reset_and_clear_data!
|
||||
reset_settings!
|
||||
clear_data!
|
||||
create_user!
|
||||
destroy_everything!
|
||||
|
||||
puts "user reset"
|
||||
end
|
||||
puts "Data cleared"
|
||||
|
||||
def reset_data!
|
||||
Family.transaction do
|
||||
reset_settings!
|
||||
clear_data!
|
||||
create_user!
|
||||
|
||||
puts "user reset"
|
||||
|
||||
create_tags!
|
||||
create_categories!
|
||||
create_merchants!
|
||||
|
||||
puts "tags, categories, merchants created"
|
||||
|
||||
create_credit_card_account!
|
||||
create_checking_account!
|
||||
create_savings_account!
|
||||
|
||||
create_investment_account!
|
||||
create_house_and_mortgage!
|
||||
create_car_and_loan!
|
||||
create_other_accounts!
|
||||
|
||||
puts "accounts created"
|
||||
puts "Demo data loaded successfully!"
|
||||
family_names.each_with_index do |family_name, index|
|
||||
create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local")
|
||||
end
|
||||
|
||||
puts "Users reset"
|
||||
end
|
||||
|
||||
def reset_data!(family_names)
|
||||
puts "Clearing existing data..."
|
||||
|
||||
destroy_everything!
|
||||
|
||||
puts "Data cleared"
|
||||
|
||||
family_names.each_with_index do |family_name, index|
|
||||
create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", data_enrichment_enabled: index == 0)
|
||||
end
|
||||
|
||||
puts "Users reset"
|
||||
|
||||
load_securities!
|
||||
|
||||
puts "Securities loaded"
|
||||
|
||||
family_names.each do |family_name|
|
||||
family = Family.find_by(name: family_name)
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
create_tags!(family)
|
||||
create_categories!(family)
|
||||
create_merchants!(family)
|
||||
|
||||
puts "tags, categories, merchants created for #{family_name}"
|
||||
|
||||
create_credit_card_account!(family)
|
||||
create_checking_account!(family)
|
||||
create_savings_account!(family)
|
||||
|
||||
create_investment_account!(family)
|
||||
create_house_and_mortgage!(family)
|
||||
create_car_and_loan!(family)
|
||||
create_other_accounts!(family)
|
||||
|
||||
create_transfer_transactions!(family)
|
||||
end
|
||||
|
||||
puts "accounts created for #{family_name}"
|
||||
end
|
||||
|
||||
puts "Demo data loaded successfully!"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :family
|
||||
|
||||
def reset_family!
|
||||
family_id = "d99e3c6e-d513-4452-8f24-dc263f8528c0" # deterministic demo id
|
||||
|
||||
family = Family.find_by(id: family_id)
|
||||
family.destroy! if family
|
||||
|
||||
Family.create!(id: family_id, name: "Demo Family", stripe_subscription_status: "active").tap(&:reload)
|
||||
end
|
||||
|
||||
def clear_data!
|
||||
def destroy_everything!
|
||||
Family.destroy_all
|
||||
Setting.destroy_all
|
||||
InviteCode.destroy_all
|
||||
User.find_by_email("user@maybe.local")&.destroy
|
||||
ExchangeRate.destroy_all
|
||||
Security.destroy_all
|
||||
Security::Price.destroy_all
|
||||
end
|
||||
|
||||
def reset_settings!
|
||||
Setting.destroy_all
|
||||
end
|
||||
def create_family_and_user!(family_name, user_email, data_enrichment_enabled: false)
|
||||
base_uuid = "d99e3c6e-d513-4452-8f24-dc263f8528c0"
|
||||
id = Digest::UUID.uuid_v5(base_uuid, family_name)
|
||||
|
||||
family = Family.create!(
|
||||
id: id,
|
||||
name: family_name,
|
||||
stripe_subscription_status: "active",
|
||||
data_enrichment_enabled: data_enrichment_enabled,
|
||||
locale: "en",
|
||||
country: "US",
|
||||
timezone: "America/New_York",
|
||||
date_format: "%m-%d-%Y"
|
||||
)
|
||||
|
||||
def create_user!
|
||||
family.users.create! \
|
||||
email: "user@maybe.local",
|
||||
email: user_email,
|
||||
first_name: "Demo",
|
||||
last_name: "User",
|
||||
role: "admin",
|
||||
password: "password",
|
||||
onboarded_at: Time.current
|
||||
|
||||
family.users.create! \
|
||||
email: "member_#{user_email}",
|
||||
first_name: "Demo (member user)",
|
||||
last_name: "User",
|
||||
role: "member",
|
||||
password: "password",
|
||||
onboarded_at: Time.current
|
||||
end
|
||||
|
||||
def create_tags!
|
||||
def create_tags!(family)
|
||||
[ "Trips", "Emergency Fund", "Demo Tag" ].each do |tag|
|
||||
family.tags.create!(name: tag)
|
||||
end
|
||||
end
|
||||
|
||||
def create_categories!
|
||||
categories = [ "Income", "Food & Drink", "Entertainment", "Travel",
|
||||
"Personal Care", "General Services", "Auto & Transport",
|
||||
"Rent & Utilities", "Home Improvement", "Shopping" ]
|
||||
def create_categories!(family)
|
||||
family.categories.bootstrap_defaults
|
||||
|
||||
categories.each do |category|
|
||||
family.categories.create!(name: category, color: COLORS.sample)
|
||||
end
|
||||
food = family.categories.find_by(name: "Food & Drink")
|
||||
family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, classification: "expense")
|
||||
family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, classification: "expense")
|
||||
family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, classification: "expense")
|
||||
end
|
||||
|
||||
def create_merchants!
|
||||
def create_merchants!(family)
|
||||
merchants = [ "Amazon", "Starbucks", "McDonald's", "Target", "Costco",
|
||||
"Home Depot", "Shell", "Whole Foods", "Walgreens", "Nike",
|
||||
"Uber", "Netflix", "Spotify", "Delta Airlines", "Airbnb", "Sephora" ]
|
||||
@@ -102,25 +128,25 @@ class Demo::Generator
|
||||
end
|
||||
end
|
||||
|
||||
def create_credit_card_account!
|
||||
def create_credit_card_account!(family)
|
||||
cc = family.accounts.create! \
|
||||
accountable: CreditCard.new,
|
||||
name: "Chase Credit Card",
|
||||
balance: 2300,
|
||||
currency: "USD"
|
||||
|
||||
50.times do
|
||||
merchant = random_family_record(Merchant)
|
||||
800.times do
|
||||
merchant = random_family_record(Merchant, family)
|
||||
create_transaction! \
|
||||
account: cc,
|
||||
name: merchant.name,
|
||||
amount: Faker::Number.positive(to: 200),
|
||||
tags: [ tag_for_merchant(merchant) ],
|
||||
category: category_for_merchant(merchant),
|
||||
tags: [ tag_for_merchant(merchant, family) ],
|
||||
category: category_for_merchant(merchant, family),
|
||||
merchant: merchant
|
||||
end
|
||||
|
||||
5.times do
|
||||
24.times do
|
||||
create_transaction! \
|
||||
account: cc,
|
||||
amount: Faker::Number.negative(from: -1000),
|
||||
@@ -128,30 +154,30 @@ class Demo::Generator
|
||||
end
|
||||
end
|
||||
|
||||
def create_checking_account!
|
||||
def create_checking_account!(family)
|
||||
checking = family.accounts.create! \
|
||||
accountable: Depository.new,
|
||||
name: "Chase Checking",
|
||||
balance: 15000,
|
||||
currency: "USD"
|
||||
|
||||
10.times do
|
||||
200.times do
|
||||
create_transaction! \
|
||||
account: checking,
|
||||
name: "Expense",
|
||||
amount: Faker::Number.positive(from: 100, to: 1000)
|
||||
end
|
||||
|
||||
10.times do
|
||||
50.times do
|
||||
create_transaction! \
|
||||
account: checking,
|
||||
amount: Faker::Number.negative(from: -2000),
|
||||
name: "Income",
|
||||
category: income_category
|
||||
category: family.categories.find_by(name: "Income")
|
||||
end
|
||||
end
|
||||
|
||||
def create_savings_account!
|
||||
def create_savings_account!(family)
|
||||
savings = family.accounts.create! \
|
||||
accountable: Depository.new,
|
||||
name: "Demo Savings",
|
||||
@@ -159,19 +185,50 @@ class Demo::Generator
|
||||
currency: "USD",
|
||||
subtype: "savings"
|
||||
|
||||
income_category = categories.find { |c| c.name == "Income" }
|
||||
income_tag = tags.find { |t| t.name == "Emergency Fund" }
|
||||
|
||||
20.times do
|
||||
100.times do
|
||||
create_transaction! \
|
||||
account: savings,
|
||||
amount: Faker::Number.negative(from: -2000),
|
||||
tags: [ income_tag ],
|
||||
category: income_category,
|
||||
tags: [ family.tags.find_by(name: "Emergency Fund") ],
|
||||
category: family.categories.find_by(name: "Income"),
|
||||
name: "Income"
|
||||
end
|
||||
end
|
||||
|
||||
def create_transfer_transactions!(family)
|
||||
checking = family.accounts.find_by(name: "Chase Checking")
|
||||
credit_card = family.accounts.find_by(name: "Chase Credit Card")
|
||||
investment = family.accounts.find_by(name: "Robinhood")
|
||||
|
||||
create_transaction!(
|
||||
account: checking,
|
||||
date: 1.day.ago.to_date,
|
||||
amount: 100,
|
||||
name: "Credit Card Payment"
|
||||
)
|
||||
|
||||
create_transaction!(
|
||||
account: credit_card,
|
||||
date: 1.day.ago.to_date,
|
||||
amount: -100,
|
||||
name: "Credit Card Payment"
|
||||
)
|
||||
|
||||
create_transaction!(
|
||||
account: checking,
|
||||
date: 3.days.ago.to_date,
|
||||
amount: 500,
|
||||
name: "Transfer to investment"
|
||||
)
|
||||
|
||||
create_transaction!(
|
||||
account: investment,
|
||||
date: 2.days.ago.to_date,
|
||||
amount: -500,
|
||||
name: "Transfer from checking"
|
||||
)
|
||||
end
|
||||
|
||||
def load_securities!
|
||||
# Create an unknown security to simulate edge cases
|
||||
Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock", exchange_mic: "UNKNOWN"
|
||||
@@ -198,9 +255,7 @@ class Demo::Generator
|
||||
end
|
||||
end
|
||||
|
||||
def create_investment_account!
|
||||
load_securities!
|
||||
|
||||
def create_investment_account!(family)
|
||||
account = family.accounts.create! \
|
||||
accountable: Investment.new,
|
||||
name: "Robinhood",
|
||||
@@ -238,7 +293,7 @@ class Demo::Generator
|
||||
end
|
||||
end
|
||||
|
||||
def create_house_and_mortgage!
|
||||
def create_house_and_mortgage!(family)
|
||||
house = family.accounts.create! \
|
||||
accountable: Property.new,
|
||||
name: "123 Maybe Way",
|
||||
@@ -256,7 +311,7 @@ class Demo::Generator
|
||||
currency: "USD"
|
||||
end
|
||||
|
||||
def create_car_and_loan!
|
||||
def create_car_and_loan!(family)
|
||||
family.accounts.create! \
|
||||
accountable: Vehicle.new,
|
||||
name: "Honda Accord",
|
||||
@@ -270,7 +325,7 @@ class Demo::Generator
|
||||
currency: "USD"
|
||||
end
|
||||
|
||||
def create_other_accounts!
|
||||
def create_other_accounts!(family)
|
||||
family.accounts.create! \
|
||||
accountable: OtherAsset.new,
|
||||
name: "Other Asset",
|
||||
@@ -289,7 +344,7 @@ class Demo::Generator
|
||||
transaction_attributes = attributes.slice(:category, :tags, :merchant)
|
||||
|
||||
entry_defaults = {
|
||||
date: Faker::Number.between(from: 0, to: 90).days.ago.to_date,
|
||||
date: Faker::Number.between(from: 0, to: 730).days.ago.to_date,
|
||||
currency: "USD",
|
||||
entryable: Account::Transaction.new(transaction_attributes)
|
||||
}
|
||||
@@ -303,69 +358,49 @@ class Demo::Generator
|
||||
date: date,
|
||||
amount: amount,
|
||||
currency: "USD",
|
||||
name: "Balance update",
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
|
||||
def random_family_record(model)
|
||||
def random_family_record(model, family)
|
||||
family_records = model.where(family_id: family.id)
|
||||
model.offset(rand(family_records.count)).first
|
||||
end
|
||||
|
||||
def category_for_merchant(merchant)
|
||||
def category_for_merchant(merchant, family)
|
||||
mapping = {
|
||||
"Amazon" => "Shopping",
|
||||
"Starbucks" => "Food & Drink",
|
||||
"McDonald's" => "Food & Drink",
|
||||
"Target" => "Shopping",
|
||||
"Costco" => "Food & Drink",
|
||||
"Home Depot" => "Home Improvement",
|
||||
"Shell" => "Auto & Transport",
|
||||
"Home Depot" => "Housing",
|
||||
"Shell" => "Transportation",
|
||||
"Whole Foods" => "Food & Drink",
|
||||
"Walgreens" => "Personal Care",
|
||||
"Walgreens" => "Healthcare",
|
||||
"Nike" => "Shopping",
|
||||
"Uber" => "Auto & Transport",
|
||||
"Netflix" => "Entertainment",
|
||||
"Spotify" => "Entertainment",
|
||||
"Delta Airlines" => "Travel",
|
||||
"Airbnb" => "Travel",
|
||||
"Sephora" => "Personal Care"
|
||||
"Uber" => "Transportation",
|
||||
"Netflix" => "Subscriptions",
|
||||
"Spotify" => "Subscriptions",
|
||||
"Delta Airlines" => "Transportation",
|
||||
"Airbnb" => "Housing",
|
||||
"Sephora" => "Shopping"
|
||||
}
|
||||
|
||||
categories.find { |c| c.name == mapping[merchant.name] }
|
||||
family.categories.find_by(name: mapping[merchant.name])
|
||||
end
|
||||
|
||||
def tag_for_merchant(merchant)
|
||||
def tag_for_merchant(merchant, family)
|
||||
mapping = {
|
||||
"Delta Airlines" => "Trips",
|
||||
"Airbnb" => "Trips"
|
||||
}
|
||||
|
||||
tag_from_merchant = tags.find { |t| t.name == mapping[merchant.name] }
|
||||
|
||||
tag_from_merchant || tags.find { |t| t.name == "Demo Tag" }
|
||||
tag_from_merchant = family.tags.find_by(name: mapping[merchant.name])
|
||||
tag_from_merchant || family.tags.find_by(name: "Demo Tag")
|
||||
end
|
||||
|
||||
def securities
|
||||
@securities ||= Security.all.to_a
|
||||
end
|
||||
|
||||
def merchants
|
||||
@merchants ||= family.merchants
|
||||
end
|
||||
|
||||
def categories
|
||||
@categories ||= family.categories
|
||||
end
|
||||
|
||||
def tags
|
||||
@tags ||= family.tags
|
||||
end
|
||||
|
||||
def income_tag
|
||||
@income_tag ||= tags.find { |t| t.name == "Emergency Fund" }
|
||||
end
|
||||
|
||||
def income_category
|
||||
@income_category ||= categories.find { |c| c.name == "Income" }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
class Family < ApplicationRecord
|
||||
include Plaidable, Syncable
|
||||
|
||||
DATE_FORMATS = [ "%m-%d-%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%m/%d/%Y", "%e/%m/%Y", "%Y.%m.%d" ]
|
||||
DATE_FORMATS = [
|
||||
[ "MM-DD-YYYY", "%m-%d-%Y" ],
|
||||
[ "DD.MM.YYYY", "%d.%m.%Y" ],
|
||||
[ "DD-MM-YYYY", "%d-%m-%Y" ],
|
||||
[ "YYYY-MM-DD", "%Y-%m-%d" ],
|
||||
[ "DD/MM/YYYY", "%d/%m/%Y" ],
|
||||
[ "YYYY/MM/DD", "%Y/%m/%d" ],
|
||||
[ "MM/DD/YYYY", "%m/%d/%Y" ],
|
||||
[ "D/MM/YYYY", "%e/%m/%Y" ],
|
||||
[ "YYYY.MM.DD", "%Y.%m.%d" ]
|
||||
].freeze
|
||||
|
||||
include Providable
|
||||
|
||||
@@ -17,9 +27,11 @@ class Family < ApplicationRecord
|
||||
has_many :issues, through: :accounts
|
||||
has_many :holdings, through: :accounts
|
||||
has_many :plaid_items, dependent: :destroy
|
||||
has_many :budgets, dependent: :destroy
|
||||
has_many :budget_categories, through: :budgets
|
||||
|
||||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
||||
validates :date_format, inclusion: { in: DATE_FORMATS }
|
||||
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
|
||||
|
||||
def sync_data(start_date: nil)
|
||||
update!(last_synced_at: Time.current)
|
||||
@@ -42,20 +54,52 @@ class Family < ApplicationRecord
|
||||
end
|
||||
|
||||
def syncing?
|
||||
super || accounts.manual.any?(&:syncing?) || plaid_items.any?(&:syncing?)
|
||||
Sync.where(
|
||||
"(syncable_type = 'Family' AND syncable_id = ?) OR
|
||||
(syncable_type = 'Account' AND syncable_id IN (SELECT id FROM accounts WHERE family_id = ? AND plaid_account_id IS NULL)) OR
|
||||
(syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?))",
|
||||
id, id, id
|
||||
).where(status: [ "pending", "syncing" ]).exists?
|
||||
end
|
||||
|
||||
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil)
|
||||
return nil unless plaid_provider
|
||||
def eu?
|
||||
country != "US" && country != "CA"
|
||||
end
|
||||
|
||||
plaid_provider.get_link_token(
|
||||
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us)
|
||||
provider = if region.to_sym == :eu
|
||||
self.class.plaid_eu_provider
|
||||
else
|
||||
self.class.plaid_us_provider
|
||||
end
|
||||
|
||||
# early return when no provider
|
||||
return nil unless provider
|
||||
|
||||
provider.get_link_token(
|
||||
user_id: id,
|
||||
webhooks_url: webhooks_url,
|
||||
redirect_url: redirect_url,
|
||||
accountable_type: accountable_type
|
||||
accountable_type: accountable_type,
|
||||
).link_token
|
||||
end
|
||||
|
||||
def income_categories_with_totals(date: Date.current)
|
||||
categories_with_stats(classification: "income", date: date)
|
||||
end
|
||||
|
||||
def expense_categories_with_totals(date: Date.current)
|
||||
categories_with_stats(classification: "expense", date: date)
|
||||
end
|
||||
|
||||
def category_stats
|
||||
CategoryStats.new(self)
|
||||
end
|
||||
|
||||
def budgeting_stats
|
||||
BudgetingStats.new(self)
|
||||
end
|
||||
|
||||
def snapshot(period = Period.all)
|
||||
query = accounts.active.joins(:balances)
|
||||
.where("account_balances.currency = ?", self.currency)
|
||||
@@ -82,7 +126,9 @@ class Family < ApplicationRecord
|
||||
|
||||
def snapshot_account_transactions
|
||||
period = Period.last_30_days
|
||||
results = accounts.active.joins(:entries)
|
||||
results = accounts.active
|
||||
.joins(:entries)
|
||||
.joins("LEFT JOIN transfers ON (transfers.inflow_transaction_id = account_entries.entryable_id OR transfers.outflow_transaction_id = account_entries.entryable_id)")
|
||||
.select(
|
||||
"accounts.*",
|
||||
"COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending",
|
||||
@@ -90,8 +136,8 @@ class Family < ApplicationRecord
|
||||
)
|
||||
.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")
|
||||
.where("account_entries.entryable_type = 'Account::Transaction'")
|
||||
.where("transfers.id IS NULL")
|
||||
.group("accounts.id")
|
||||
.having("SUM(ABS(account_entries.amount)) > 0")
|
||||
.to_a
|
||||
@@ -110,9 +156,7 @@ class Family < ApplicationRecord
|
||||
end
|
||||
|
||||
def snapshot_transactions
|
||||
candidate_entries = entries.account_transactions.without_transfers.excluding(
|
||||
entries.joins(:account).where(amount: ..0, accounts: { classification: Account.classifications[:liability] })
|
||||
)
|
||||
candidate_entries = entries.account_transactions.incomes_and_expenses
|
||||
rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days)
|
||||
|
||||
spending = []
|
||||
@@ -131,7 +175,7 @@ class Family < ApplicationRecord
|
||||
|
||||
savings << {
|
||||
date: r.date,
|
||||
value: r.rolling_income != 0 ? (r.rolling_income - r.rolling_spend) / r.rolling_income : 0.to_d
|
||||
value: r.rolling_income != 0 ? ((r.rolling_income - r.rolling_spend) / r.rolling_income) : 0.to_d
|
||||
}
|
||||
end
|
||||
|
||||
@@ -173,4 +217,45 @@ class Family < ApplicationRecord
|
||||
def primary_user
|
||||
users.order(:created_at).first
|
||||
end
|
||||
|
||||
def oldest_entry_date
|
||||
entries.order(:date).first&.date || Date.current
|
||||
end
|
||||
|
||||
def active_accounts_count
|
||||
accounts.active.count
|
||||
end
|
||||
|
||||
private
|
||||
CategoriesWithTotals = Struct.new(:total_money, :category_totals, keyword_init: true)
|
||||
CategoryWithStats = Struct.new(:category, :amount_money, :percentage, keyword_init: true)
|
||||
|
||||
def categories_with_stats(classification:, date: Date.current)
|
||||
totals = category_stats.month_category_totals(date: date)
|
||||
|
||||
classified_totals = totals.category_totals.select { |t| t.classification == classification }
|
||||
|
||||
if classification == "income"
|
||||
total = totals.total_income
|
||||
categories_scope = categories.incomes
|
||||
else
|
||||
total = totals.total_expense
|
||||
categories_scope = categories.expenses
|
||||
end
|
||||
|
||||
categories_with_uncategorized = categories_scope + [ categories_scope.uncategorized ]
|
||||
|
||||
CategoriesWithTotals.new(
|
||||
total_money: Money.new(total, currency),
|
||||
category_totals: categories_with_uncategorized.map do |category|
|
||||
ct = classified_totals.find { |ct| ct.category_id == category&.id }
|
||||
|
||||
CategoryWithStats.new(
|
||||
category: category,
|
||||
amount_money: Money.new(ct&.amount || 0, currency),
|
||||
percentage: ct&.percentage || 0
|
||||
)
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,10 +25,15 @@ class Period
|
||||
end
|
||||
|
||||
BUILTIN = [
|
||||
new(name: "all", date_range: nil..Date.current),
|
||||
new(name: "last_7_days", date_range: 7.days.ago.to_date..Date.current),
|
||||
new(name: "last_30_days", date_range: 30.days.ago.to_date..Date.current),
|
||||
new(name: "last_365_days", date_range: 365.days.ago.to_date..Date.current)
|
||||
new(name: "all", date_range: nil..Date.current),
|
||||
new(name: "current_week", date_range: Date.current.beginning_of_week..Date.current),
|
||||
new(name: "last_7_days", date_range: 7.days.ago.to_date..Date.current),
|
||||
new(name: "current_month", date_range: Date.current.beginning_of_month..Date.current),
|
||||
new(name: "last_30_days", date_range: 30.days.ago.to_date..Date.current),
|
||||
new(name: "current_quarter", date_range: Date.current.beginning_of_quarter..Date.current),
|
||||
new(name: "last_90_days", date_range: 90.days.ago.to_date..Date.current),
|
||||
new(name: "current_year", date_range: Date.current.beginning_of_year..Date.current),
|
||||
new(name: "last_365_days", date_range: 365.days.ago.to_date..Date.current)
|
||||
]
|
||||
|
||||
INDEX = BUILTIN.index_by(&:name)
|
||||
|
||||
@@ -89,7 +89,6 @@ class PlaidAccount < ApplicationRecord
|
||||
t.amount = plaid_txn.amount
|
||||
t.currency = plaid_txn.iso_currency_code
|
||||
t.date = plaid_txn.date
|
||||
t.marked_as_transfer = transfer?(plaid_txn)
|
||||
t.entryable = Account::Transaction.new(
|
||||
category: get_category(plaid_txn.personal_finance_category.primary),
|
||||
merchant: get_merchant(plaid_txn.merchant_name)
|
||||
|
||||
@@ -31,7 +31,6 @@ class PlaidInvestmentSync
|
||||
t.amount = transaction.amount
|
||||
t.currency = transaction.iso_currency_code
|
||||
t.date = transaction.date
|
||||
t.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal])
|
||||
t.entryable = Account::Transaction.new
|
||||
end
|
||||
else
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class PlaidItem < ApplicationRecord
|
||||
include Plaidable, Syncable
|
||||
|
||||
enum :plaid_region, { us: "us", eu: "eu" }
|
||||
|
||||
if Rails.application.credentials.active_record_encryption.present?
|
||||
encrypts :access_token, deterministic: true
|
||||
end
|
||||
@@ -19,13 +21,14 @@ class PlaidItem < ApplicationRecord
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
class << self
|
||||
def create_from_public_token(token, item_name:)
|
||||
response = plaid_provider.exchange_public_token(token)
|
||||
def create_from_public_token(token, item_name:, region:)
|
||||
response = plaid_provider_for_region(region).exchange_public_token(token)
|
||||
|
||||
new_plaid_item = create!(
|
||||
name: item_name,
|
||||
plaid_id: response.item_id,
|
||||
access_token: response.access_token,
|
||||
plaid_region: region
|
||||
)
|
||||
|
||||
new_plaid_item.sync_later
|
||||
@@ -131,5 +134,7 @@ class PlaidItem < ApplicationRecord
|
||||
|
||||
def remove_plaid_item
|
||||
plaid_provider.remove_item(access_token)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,8 @@ class Property < ApplicationRecord
|
||||
[ "Multi-Family Home", "multi_family_home" ],
|
||||
[ "Condominium", "condominium" ],
|
||||
[ "Townhouse", "townhouse" ],
|
||||
[ "Investment Property", "investment_property" ]
|
||||
[ "Investment Property", "investment_property" ],
|
||||
[ "Second Home", "second_home" ]
|
||||
]
|
||||
|
||||
has_one :address, as: :addressable, dependent: :destroy
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Provider::Plaid
|
||||
attr_reader :client
|
||||
attr_reader :client, :region
|
||||
|
||||
MAYBE_SUPPORTED_PLAID_PRODUCTS = %w[transactions investments liabilities].freeze
|
||||
MAX_HISTORY_DAYS = Rails.env.development? ? 90 : 730
|
||||
@@ -54,18 +54,13 @@ class Provider::Plaid
|
||||
actual_hash = Digest::SHA256.hexdigest(raw_body)
|
||||
raise JWT::VerificationError, "Invalid webhook body hash" unless ActiveSupport::SecurityUtils.secure_compare(expected_hash, actual_hash)
|
||||
end
|
||||
|
||||
def client
|
||||
api_client = Plaid::ApiClient.new(
|
||||
Rails.application.config.plaid
|
||||
)
|
||||
|
||||
Plaid::PlaidApi.new(api_client)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize
|
||||
@client = self.class.client
|
||||
def initialize(config, region)
|
||||
@client = Plaid::PlaidApi.new(
|
||||
Plaid::ApiClient.new(config)
|
||||
)
|
||||
@region = region
|
||||
end
|
||||
|
||||
def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil)
|
||||
@@ -74,7 +69,7 @@ class Provider::Plaid
|
||||
client_name: "Maybe Finance",
|
||||
products: [ get_primary_product(accountable_type) ],
|
||||
additional_consented_products: get_additional_consented_products(accountable_type),
|
||||
country_codes: [ "US" ],
|
||||
country_codes: country_codes,
|
||||
language: "en",
|
||||
webhook: webhooks_url,
|
||||
redirect_uri: redirect_url,
|
||||
@@ -198,4 +193,12 @@ class Provider::Plaid
|
||||
def get_additional_consented_products(accountable_type)
|
||||
MAYBE_SUPPORTED_PLAID_PRODUCTS - [ get_primary_product(accountable_type) ]
|
||||
end
|
||||
|
||||
def country_codes
|
||||
if region.to_sym == :eu
|
||||
[ "ES", "NL", "FR", "IE", "DE", "IT", "PL", "DK", "NO", "SE", "EE", "LT", "LV", "PT", "BE" ] # EU supported countries
|
||||
else
|
||||
[ "US", "CA" ] # US + CA only
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -167,6 +167,35 @@ class Provider::Synth
|
||||
raw_response: response
|
||||
end
|
||||
|
||||
def enrich_transaction(description, amount: nil, date: nil, city: nil, state: nil, country: nil)
|
||||
params = {
|
||||
description: description,
|
||||
amount: amount,
|
||||
date: date,
|
||||
city: city,
|
||||
state: state,
|
||||
country: country
|
||||
}.compact
|
||||
|
||||
response = client.get("#{base_url}/enrich", params)
|
||||
|
||||
parsed = JSON.parse(response.body)
|
||||
|
||||
EnrichTransactionResponse.new \
|
||||
info: EnrichTransactionInfo.new(
|
||||
name: parsed.dig("merchant"),
|
||||
icon_url: parsed.dig("icon"),
|
||||
category: parsed.dig("category")
|
||||
),
|
||||
success?: true,
|
||||
raw_response: response
|
||||
rescue StandardError => error
|
||||
EnrichTransactionResponse.new \
|
||||
success?: false,
|
||||
error: error,
|
||||
raw_response: error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :api_key
|
||||
@@ -177,6 +206,8 @@ class Provider::Synth
|
||||
UsageResponse = Struct.new :used, :limit, :utilization, :plan, :success?, :error, :raw_response, keyword_init: true
|
||||
SearchSecuritiesResponse = Struct.new :securities, :success?, :error, :raw_response, keyword_init: true
|
||||
SecurityInfoResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true
|
||||
EnrichTransactionResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true
|
||||
EnrichTransactionInfo = Struct.new :name, :icon_url, :category, keyword_init: true
|
||||
|
||||
def base_url
|
||||
ENV["SYNTH_URL"] || "https://api.synthfinance.com"
|
||||
|
||||
4
app/models/rejected_transfer.rb
Normal file
4
app/models/rejected_transfer.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class RejectedTransfer < ApplicationRecord
|
||||
belongs_to :inflow_transaction, class_name: "Account::Transaction"
|
||||
belongs_to :outflow_transaction, class_name: "Account::Transaction"
|
||||
end
|
||||
@@ -20,4 +20,6 @@ class Setting < RailsSettings::Base
|
||||
field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"]
|
||||
|
||||
field :require_invite_for_signup, type: :boolean, default: false
|
||||
|
||||
field :require_email_confirmation, type: :boolean, default: ENV.fetch("REQUIRE_EMAIL_CONFIRMATION", "true") == "true"
|
||||
end
|
||||
|
||||
@@ -34,6 +34,11 @@ class Sync < ApplicationRecord
|
||||
scope.set_context("sync", { id: id })
|
||||
end
|
||||
|
||||
update! status: :failed, error: error.message, last_ran_at: Time.current
|
||||
update!(
|
||||
status: :failed,
|
||||
error: error.message,
|
||||
error_backtrace: error.backtrace&.first(10),
|
||||
last_ran_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,6 +37,14 @@ class TimeSeries
|
||||
series: self
|
||||
end
|
||||
|
||||
def empty?
|
||||
values.empty?
|
||||
end
|
||||
|
||||
def has_current_day_value?
|
||||
values.any? { |v| v.date == Date.current }
|
||||
end
|
||||
|
||||
# `as_json` returns the data shape used by D3 charts
|
||||
def as_json
|
||||
{
|
||||
|
||||
129
app/models/transfer.rb
Normal file
129
app/models/transfer.rb
Normal file
@@ -0,0 +1,129 @@
|
||||
class Transfer < ApplicationRecord
|
||||
belongs_to :inflow_transaction, class_name: "Account::Transaction"
|
||||
belongs_to :outflow_transaction, class_name: "Account::Transaction"
|
||||
|
||||
enum :status, { pending: "pending", confirmed: "confirmed" }
|
||||
|
||||
validates :inflow_transaction_id, uniqueness: true
|
||||
validates :outflow_transaction_id, uniqueness: true
|
||||
|
||||
validate :transfer_has_different_accounts
|
||||
validate :transfer_has_opposite_amounts
|
||||
validate :transfer_within_date_range
|
||||
validate :transfer_has_same_family
|
||||
|
||||
class << self
|
||||
def from_accounts(from_account:, to_account:, date:, amount:)
|
||||
# Attempt to convert the amount to the to_account's currency.
|
||||
# If the conversion fails, use the original amount.
|
||||
converted_amount = begin
|
||||
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
|
||||
rescue Money::ConversionError
|
||||
Money.new(amount.abs, from_account.currency)
|
||||
end
|
||||
|
||||
new(
|
||||
inflow_transaction: Account::Transaction.new(
|
||||
entry: to_account.entries.build(
|
||||
amount: converted_amount.amount.abs * -1,
|
||||
currency: converted_amount.currency.iso_code,
|
||||
date: date,
|
||||
name: "Transfer from #{from_account.name}",
|
||||
entryable: Account::Transaction.new
|
||||
)
|
||||
),
|
||||
outflow_transaction: Account::Transaction.new(
|
||||
entry: from_account.entries.build(
|
||||
amount: amount.abs,
|
||||
currency: from_account.currency,
|
||||
date: date,
|
||||
name: "Transfer to #{to_account.name}",
|
||||
entryable: Account::Transaction.new
|
||||
)
|
||||
),
|
||||
status: "confirmed"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def reject!
|
||||
Transfer.transaction do
|
||||
RejectedTransfer.find_or_create_by!(inflow_transaction_id: inflow_transaction_id, outflow_transaction_id: outflow_transaction_id)
|
||||
destroy!
|
||||
end
|
||||
end
|
||||
|
||||
def confirm!
|
||||
update!(status: "confirmed")
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
inflow_transaction.entry.sync_account_later
|
||||
outflow_transaction.entry.sync_account_later
|
||||
end
|
||||
|
||||
def belongs_to_family?(family)
|
||||
family.transactions.include?(inflow_transaction)
|
||||
end
|
||||
|
||||
def to_account
|
||||
inflow_transaction.entry.account
|
||||
end
|
||||
|
||||
def from_account
|
||||
outflow_transaction.entry.account
|
||||
end
|
||||
|
||||
def amount_abs
|
||||
inflow_transaction.entry.amount_money.abs
|
||||
end
|
||||
|
||||
def name
|
||||
if payment?
|
||||
I18n.t("transfer.payment_name", to_account: to_account.name)
|
||||
else
|
||||
I18n.t("transfer.name", to_account: to_account.name)
|
||||
end
|
||||
end
|
||||
|
||||
def payment?
|
||||
to_account.liability?
|
||||
end
|
||||
|
||||
def categorizable?
|
||||
to_account.accountable_type == "Loan"
|
||||
end
|
||||
|
||||
private
|
||||
def transfer_has_different_accounts
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
errors.add(:base, :must_be_from_different_accounts) if inflow_transaction.entry.account == outflow_transaction.entry.account
|
||||
end
|
||||
|
||||
def transfer_has_same_family
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
errors.add(:base, :must_be_from_same_family) unless inflow_transaction.entry.account.family == outflow_transaction.entry.account.family
|
||||
end
|
||||
|
||||
def transfer_has_opposite_amounts
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
|
||||
inflow_amount = inflow_transaction.entry.amount
|
||||
outflow_amount = outflow_transaction.entry.amount
|
||||
|
||||
if inflow_transaction.entry.currency == outflow_transaction.entry.currency
|
||||
# For same currency, amounts must be exactly opposite
|
||||
errors.add(:base, :must_have_opposite_amounts) if inflow_amount + outflow_amount != 0
|
||||
else
|
||||
# For different currencies, just check the signs are opposite
|
||||
errors.add(:base, :must_have_opposite_amounts) unless inflow_amount.negative? && outflow_amount.positive?
|
||||
end
|
||||
end
|
||||
|
||||
def transfer_within_date_range
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
|
||||
date_diff = (inflow_transaction.entry.date - outflow_transaction.entry.date).abs
|
||||
errors.add(:base, :must_be_within_date_range) if date_diff > 4
|
||||
end
|
||||
end
|
||||
@@ -7,16 +7,18 @@ class User < ApplicationRecord
|
||||
has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy
|
||||
accepts_nested_attributes_for :family, update_only: true
|
||||
|
||||
validates :email, presence: true, uniqueness: true
|
||||
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validate :ensure_valid_profile_image
|
||||
normalizes :email, with: ->(email) { email.strip.downcase }
|
||||
normalizes :unconfirmed_email, with: ->(email) { email&.strip&.downcase }
|
||||
|
||||
normalizes :first_name, :last_name, with: ->(value) { value.strip.presence }
|
||||
|
||||
enum :role, { member: "member", admin: "admin", super_admin: "super_admin" }, validate: true
|
||||
|
||||
has_one_attached :profile_image do |attachable|
|
||||
attachable.variant :thumbnail, resize_to_fill: [ 300, 300 ]
|
||||
attachable.variant :thumbnail, resize_to_fill: [ 300, 300 ], convert: :webp, saver: { quality: 80 }
|
||||
attachable.variant :small, resize_to_fill: [ 72, 72 ], convert: :webp, saver: { quality: 80 }
|
||||
end
|
||||
|
||||
validate :profile_image_size
|
||||
@@ -25,6 +27,30 @@ class User < ApplicationRecord
|
||||
password_salt&.last(10)
|
||||
end
|
||||
|
||||
generates_token_for :email_confirmation, expires_in: 1.day do
|
||||
unconfirmed_email
|
||||
end
|
||||
|
||||
def pending_email_change?
|
||||
unconfirmed_email.present?
|
||||
end
|
||||
|
||||
def initiate_email_change(new_email)
|
||||
return false if new_email == email
|
||||
return false if new_email == unconfirmed_email
|
||||
|
||||
if Rails.application.config.app_mode.self_hosted? && !Setting.require_email_confirmation
|
||||
update(email: new_email)
|
||||
else
|
||||
if update(unconfirmed_email: new_email)
|
||||
EmailConfirmationMailer.with(user: self).confirmation_email.deliver_later
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def request_impersonation_for(user_id)
|
||||
impersonated = User.find(user_id)
|
||||
impersonator_support_sessions.create!(impersonated: impersonated)
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
<%= turbo_frame_tag dom_id(@account, "entries") do %>
|
||||
<div class="bg-white p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<%= tag.h2 t(".title"), class: "font-medium text-lg" %>
|
||||
<% unless @account.plaid_account_id.present? %>
|
||||
<div data-controller="menu" data-testid="activity-menu">
|
||||
<button class="btn btn--secondary flex items-center gap-2" data-menu-target="button">
|
||||
<%= lucide_icon("plus", class: "w-4 h-4") %>
|
||||
<%= tag.span t(".new") %>
|
||||
</button>
|
||||
<div data-menu-target="content" class="z-10 hidden bg-white rounded-lg border border-alpha-black-25 shadow-xs p-1">
|
||||
<%= link_to new_account_valuation_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
|
||||
<%= lucide_icon("circle-dollar-sign", class: "text-gray-500 w-5 h-5") %>
|
||||
<%= tag.span t(".new_balance"), class: "text-sm" %>
|
||||
<% end %>
|
||||
|
||||
<% unless @account.crypto? %>
|
||||
<%= link_to @account.investment? ? new_account_trade_path(account_id: @account.id) : new_account_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
|
||||
<%= lucide_icon("credit-card", class: "text-gray-500 w-5 h-5") %>
|
||||
<%= tag.span t(".new_transaction"), class: "text-sm" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form_with url: account_entries_path,
|
||||
id: "entries-search",
|
||||
scope: :q,
|
||||
method: :get,
|
||||
data: { controller: "auto-submit-form" } do |form| %>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="grow">
|
||||
<div class="flex items-center px-3 py-2 gap-2 border border-gray-200 rounded-lg focus-within:ring-gray-100 focus-within:border-gray-900">
|
||||
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500") %>
|
||||
<%= hidden_field_tag :account_id, @account.id %>
|
||||
<%= form.search_field :search,
|
||||
placeholder: "Search entries by name",
|
||||
value: @q[:search],
|
||||
class: "form-field__input placeholder:text-sm placeholder:text-gray-500",
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @entries.empty? %>
|
||||
<p class="text-gray-500 text-sm p-4"><%= t(".no_entries") %></p>
|
||||
<% else %>
|
||||
<%= tag.div id: dom_id(@account, "entries_bulk_select"),
|
||||
data: {
|
||||
controller: "bulk-select",
|
||||
bulk_select_singular_label_value: t(".entry"),
|
||||
bulk_select_plural_label_value: t(".entries")
|
||||
} do %>
|
||||
<div id="entry-selection-bar" data-bulk-select-target="selectionBar" class="flex justify-center hidden">
|
||||
<%= render "account/entries/selection_bar" %>
|
||||
</div>
|
||||
|
||||
<div class="grid bg-gray-25 rounded-xl grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-5 py-3 mb-4">
|
||||
<div class="pl-0.5 col-span-8 flex items-center gap-4">
|
||||
<%= check_box_tag "selection_entry",
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
data: { action: "bulk-select#togglePageSelection" } %>
|
||||
<p><%= t(".date") %></p>
|
||||
</div>
|
||||
<%= tag.p t(".amount"), class: "col-span-2 justify-self-end" %>
|
||||
<%= tag.p t(".balance"), class: "col-span-2 justify-self-end" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="rounded-tl-lg rounded-tr-lg bg-white border-alpha-black-25 shadow-xs">
|
||||
<div class="space-y-4">
|
||||
<% calculator = Account::BalanceTrendCalculator.for(@entries) %>
|
||||
<%= entries_by_date(@entries) do |entries| %>
|
||||
<% entries.each do |entry| %>
|
||||
<%= render entry, balance_trend: calculator&.trend_for(entry) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-white rounded-bl-lg rounded-br-lg">
|
||||
<%= render "pagination", pagy: @pagy %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -3,7 +3,7 @@
|
||||
<%= turbo_frame_tag dom_id(holding) do %>
|
||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||
<div class="col-span-4 flex items-center gap-4">
|
||||
<%= image_tag "https://logo.synthfinance.com/ticker/#{holding.ticker}", class: "w-9 h-9 rounded-full" %>
|
||||
<%= image_tag "https://logo.synthfinance.com/ticker/#{holding.ticker}", class: "w-9 h-9 rounded-full", loading: "lazy" %>
|
||||
|
||||
<div class="space-y-0.5">
|
||||
<%= link_to holding.name, account_holding_path(holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<%= tag.p @holding.ticker, class: "text-sm text-gray-500" %>
|
||||
</div>
|
||||
|
||||
<%= image_tag "https://logo.synthfinance.com/ticker/#{@holding.ticker}", class: "w-9 h-9 rounded-full" %>
|
||||
<%= image_tag "https://logo.synthfinance.com/ticker/#{@holding.ticker}", loading: "lazy", class: "w-9 h-9 rounded-full" %>
|
||||
</header>
|
||||
|
||||
<details class="group space-y-2" open>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{ label: t(".type"), selected: type },
|
||||
{ data: {
|
||||
action: "trade-form#changeType",
|
||||
trade_form_url_param: new_account_trade_path(account_id: entry.account_id),
|
||||
trade_form_url_param: new_account_trade_path(account_id: entry.account&.id || entry.account_id),
|
||||
trade_form_key_param: "type",
|
||||
}} %>
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<dd class="text-gray-900"><%= trade.security.ticker %></dd>
|
||||
</div>
|
||||
|
||||
<% if trade.buy? %>
|
||||
<% if trade.qty.positive? %>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-gray-500"><%= t(".purchase_qty_label") %></dt>
|
||||
<dd class="text-gray-900"><%= trade.qty.abs %></dd>
|
||||
@@ -53,7 +53,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if trade.buy? && trade.unrealized_gain_loss.present? %>
|
||||
<% if trade.qty.positive? && trade.unrealized_gain_loss.present? %>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-gray-500"><%= t(".total_return_label") %></dt>
|
||||
<dd style="color: <%= trade.unrealized_gain_loss.color %>;">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<% trade, account = entry.account_trade, entry.account %>
|
||||
|
||||
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
|
||||
<div class="col-span-8 flex items-center gap-4">
|
||||
<div class="col-span-6 flex items-center gap-4">
|
||||
<% if selectable %>
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
@@ -13,14 +13,14 @@
|
||||
<div class="max-w-full">
|
||||
<%= 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">
|
||||
<%= trade.name.first.upcase %>
|
||||
<%= entry.display_name.first.upcase %>
|
||||
</div>
|
||||
|
||||
<div class="truncate">
|
||||
<% if entry.new_record? %>
|
||||
<%= content_tag :p, trade.name %>
|
||||
<%= content_tag :p, entry.display_name %>
|
||||
<% else %>
|
||||
<%= link_to trade.name,
|
||||
<%= link_to entry.display_name,
|
||||
account_entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
@@ -30,6 +30,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 flex items-center">
|
||||
<%= render "categories/badge", category: trade_category %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 justify-self-end font-medium text-sm">
|
||||
<%= content_tag :p,
|
||||
format_money(-entry.amount_money),
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<p class="text-gray-500 py-4"><%= t(".no_trades") %></p>
|
||||
<% else %>
|
||||
<div class="space-y-6">
|
||||
<%= entries_by_date(@entries) do |entries| %>
|
||||
<%= entries_by_date(@entries) do |entries, _transfers| %>
|
||||
<%= render partial: "account/trades/trade", collection: entries, as: :entry %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user