Compare commits

...

41 Commits

Author SHA1 Message Date
Zach Gollwitzer
56ab092f6b Bump to v0.2.0-alpha.2
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-11-08 14:55:56 -05:00
Tony Vincent
b3ef995d1f Fix duplicate invites (#1437)
Co-authored-by: Josh Pigford <josh@joshpigford.com>
2024-11-08 09:58:35 -06:00
bruno costanzo
a113d573d6 Skip account valuation on entry balance_after_entry (#1435) 2024-11-08 09:17:55 -05:00
Tony Vincent
3b928775a8 Fix timeframe dropdown next to Portfolio (#1434) 2024-11-08 09:10:05 -05:00
Josh Pigford
31d9d926f7 Fix for certain securities returning incorrect prices 2024-11-07 11:52:16 -06:00
Tony Vincent
154a1a971b Exclude inactive accounts from networth calculation and from sidebar (#1432) 2024-11-07 10:14:12 -05:00
Tony Vincent
e434ed0e1f Add text-overflow: ellipsis property for account name display (#1431) 2024-11-07 08:42:51 -06:00
Zach Gollwitzer
2722254be9 Sync account after balance deletion
- Fixes #1416
- Fixes timezone bugs in forms
2024-11-05 19:31:24 -05:00
Zach Gollwitzer
455257bf51 Show onboarding unless invitation present
Fixes #1421
2024-11-05 19:08:45 -05:00
Zach Gollwitzer
f2739b79fb Improve password reset flow, normalize translations 2024-11-05 17:15:29 -05:00
Zach Gollwitzer
cee9692b35 Update Ruby version note
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-11-05 11:06:36 -05:00
Luis Ezcurdia
18266c3352 Bump ruby version to 3.3.5 (#1402) 2024-11-05 11:05:08 -05:00
dependabot[bot]
c3400856c7 Bump rails from 7.2.1.2 to 7.2.2 (#1410)
Bumps [rails](https://github.com/rails/rails) from 7.2.1.2 to 7.2.2.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](https://github.com/rails/rails/compare/v7.2.1.2...v7.2.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-11-05 08:17:40 -05:00
dependabot[bot]
a0ad33e47c Bump stripe from 13.0.2 to 13.1.0 (#1411)
Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.0.2 to 13.1.0.
- [Release notes](https://github.com/stripe/stripe-ruby/releases)
- [Changelog](https://github.com/stripe/stripe-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-ruby/compare/v13.0.2...v13.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-11-05 08:16:11 -05:00
dependabot[bot]
65d46397d7 Bump pagy from 9.1.0 to 9.1.1 (#1409)
Bumps [pagy](https://github.com/ddnexus/pagy) from 9.1.0 to 9.1.1.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/9.1.0...9.1.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-11-05 08:15:52 -05:00
dependabot[bot]
905eb7bbe8 Bump selenium-webdriver from 4.25.0 to 4.26.0 (#1412)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.25.0 to 4.26.0.
- [Release notes](https://github.com/SeleniumHQ/selenium/releases)
- [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES)
- [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.25.0...selenium-4.26.0)

---
updated-dependencies:
- dependency-name: selenium-webdriver
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-11-05 08:15:40 -05:00
Zach Gollwitzer
65db49273c Account Activity View + Account Forms (#1406)
* Remove balance mode, sketch out refactor

* Activity view checkpoint

* Entry partials, checkpoint

* Finish txn partial

* Give entries context when editing for different turbo responses

* Calculate change of balance for each entry

* Account tabs consolidation

* Translations, linting, brakeman updates

* Account actions concern

* Finalize forms, get account system tests passing

* Get tests passing

* Lint, rubocop, schema updates

* Improve routing and stream responses

* Fix broken routes

* Add import option for adding accounts

* Fix system test

* Fix test specificity

* Fix sparklines

* Improve account redirects
2024-11-04 20:27:31 -05:00
alekseyp
12e4f1067d Update en.yml (#1401)
Signed-off-by: alekseyp <aleksey.potaneyko@gmail.com>
2024-11-01 15:01:47 -05:00
Josh Pigford
85779b4038 super_admin should be valid for admin 2024-11-01 10:30:30 -05:00
Josh Pigford
793bd852a0 Family invites (#1397)
* Initial pass at household invites

* Invitee setup

* Clean up add member form

* Lint and other tweaks

* Security cleanup

* Lint

* i18n fixes

* More i18n cleanup

* Show pending invites

* Don't use turbo on the form

* Improved email design

* Basic tests

* Lint

* Update onboardings_controller.rb

* Registration + invite cleanup

* Lint

* Update brakeman.ignore

* Update brakeman.ignore

* Self host invite links

* Test tweaks

* Address missing param error
2024-11-01 10:23:27 -05:00
Zach Gollwitzer
09b269273a Safe load yaml files 2024-11-01 09:42:00 -04:00
Harshit Chaudhary
47288a1629 Auto naming of Transfer Transaction (#1393)
* Remove Description field

* Auto naming of tranfer transaction

* Fix transfer test

* Improve Transfer entries names
2024-11-01 08:58:19 -04:00
Tony Vincent
2b61821336 Do not include income transactions in liability accounts for savings rate (#1385)
* Do not include income transactions in liability accounts for savings rate

* Do not include income in liability accounts in savings rate chart
2024-10-31 09:05:01 -04:00
Nico
7946cd7819 Adds condition to skip link to transaction form if it's not editable (#1394) 2024-10-31 08:57:06 -04:00
Josh Pigford
e7f09e6f71 Groundwork for security info (#1396)
* Groundwork for security info

* Lint
2024-10-30 18:08:19 -04:00
Josh Pigford
5e2b932648 Use Synth logo for holdings 2024-10-30 12:14:11 -04:00
Josh Pigford
5533b84895 Always include US stocks 2024-10-30 10:42:57 -04:00
Zach Gollwitzer
c9917674aa Add validation to security price model 2024-10-30 09:51:05 -04:00
Josh Pigford
cd91e66618 Initial pass at Synth-based ticker selection (#1392)
* Initial pass at Synth-based ticker selection

* Update _tickers.turbo_stream.erb

* Functional combobox display

* A few cleanup steps

* Linter

* Prevent long strings

* Another step towards functional combobox

* Deprecated files

* Custom Combobox implementation

* Lint

* Test suite fixes

* Lint

* Make direct use of mic codes

* Update splits

* Update trades_test.rb
2024-10-30 09:23:44 -04:00
Josh Pigford
490f44589e First pass at security price reference (#1388)
* First pass at security price reference

* Data cleanup

* Synth security fetching does better with a mic_code

* Update test suite

😭

* Update schema.rb

* Update generator.rb
2024-10-29 15:37:59 -04:00
Zach Gollwitzer
bf695972e4 Remove missing prices issue (#1390) 2024-10-29 14:55:46 -04:00
Josh Pigford
7d8028b505 Stock filter (#1376)
* Initial pass at stock filtering

* Rough in filter

* Cleaning up security listing

* Tweak to search function

* Combobox tweaks

* Clean up search query

* Update trades test with combobox

* Update securities.yml
2024-10-28 15:49:19 -04:00
Josh Pigford
c2561b5fb4 Handle manually entered securities 2024-10-28 13:33:27 -04:00
Zach Gollwitzer
e5eb69bdc7 Fix hidden selection bars on account views 2024-10-28 11:29:52 -04:00
Guillem Arias Fauste
3cd364af09 fix bulk action bar positioning (#1370)
* fix bulk action bar positioning

* remove extra space
2024-10-28 08:02:49 -04:00
dependabot[bot]
277fb3dc39 Bump mocha from 2.4.5 to 2.5.0 (#1378)
Bumps [mocha](https://github.com/freerange/mocha) from 2.4.5 to 2.5.0.
- [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md)
- [Commits](https://github.com/freerange/mocha/compare/v2.4.5...v2.5.0)

---
updated-dependencies:
- dependency-name: mocha
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-28 07:56:44 -04:00
dependabot[bot]
439e50bb3e Bump pg from 1.5.8 to 1.5.9 (#1379)
Bumps [pg](https://github.com/ged/ruby-pg) from 1.5.8 to 1.5.9.
- [Changelog](https://github.com/ged/ruby-pg/blob/master/History.md)
- [Commits](https://github.com/ged/ruby-pg/compare/v1.5.8...v1.5.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-28 07:56:36 -04:00
dependabot[bot]
2141cbb041 Bump rails from 7.2.1.1 to 7.2.1.2 (#1380)
Bumps [rails](https://github.com/rails/rails) from 7.2.1.1 to 7.2.1.2.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](https://github.com/rails/rails/compare/v7.2.1.1...v7.2.1.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-28 07:56:25 -04:00
dependabot[bot]
d78f582af2 Bump ruby-lsp-rails from 0.3.20 to 0.3.21 (#1381)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.20 to 0.3.21.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.20...v0.3.21)

---
updated-dependencies:
- dependency-name: ruby-lsp-rails
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-28 07:56:01 -04:00
dependabot[bot]
2adb54da99 Bump stripe from 13.0.1 to 13.0.2 (#1382)
Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.0.1 to 13.0.2.
- [Release notes](https://github.com/stripe/stripe-ruby/releases)
- [Changelog](https://github.com/stripe/stripe-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-ruby/compare/v13.0.1...v13.0.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-28 07:55:50 -04:00
Josh Pigford
45935db5f3 Remove dependency on stock exchange table (#1368) 2024-10-25 13:09:02 -05:00
299 changed files with 3089 additions and 2155 deletions

View File

@@ -1,4 +1,4 @@
ARG RUBY_VERSION=3.3.4
ARG RUBY_VERSION=3.3.5
FROM ruby:${RUBY_VERSION}-slim-bullseye
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \

View File

@@ -11,14 +11,10 @@
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
PORT=3000
# Exchange Rate & US Stock Pricing API
# This is used to convert between different currencies in the app. In addition, it fetches US stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
# Exchange Rate & Stock Pricing API
# This is used to convert between different currencies in the app. In addition, it fetches global stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
SYNTH_API_KEY=
# Non-US Stock Pricing API
# This is used to fetch non-US stock prices. We use Marketstack.com for this and while they offer a free tier, it is quite limited. You'll almost certainly need their Basic plan, which is $9.99 per month.
MARKETSTACK_API_KEY=
# SMTP Configuration
# This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports).
# Resend.com is a good option that offers a free tier for sending emails.

View File

@@ -3,6 +3,3 @@ SELF_HOSTED=false
# Enable Synth market data (careful, this will use your API credits)
SYNTH_API_KEY=yourapikeyhere
# Enable Marketstack market data (careful, this will use your API credits)
MARKETSTACK_API_KEY=yourapikeyhere

View File

@@ -1 +1 @@
3.3.4
3.3.5

View File

@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.3.4
ARG RUBY_VERSION=3.3.5
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
# Rails app lives here

View File

@@ -3,7 +3,7 @@ source "https://rubygems.org"
ruby file: ".ruby-version"
# Rails
gem "rails", "~> 7.2.1"
gem "rails", "~> 7.2.2"
# Drivers
gem "pg", "~> 1.5"
@@ -21,6 +21,7 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails"
# Hotwire
gem "stimulus-rails"
gem "turbo-rails"
gem "hotwire_combobox"
# Background Jobs
gem "good_job"

View File

@@ -8,29 +8,29 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.1.1)
actionpack (= 7.2.1.1)
activesupport (= 7.2.1.1)
actioncable (7.2.2)
actionpack (= 7.2.2)
activesupport (= 7.2.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.1.1)
actionpack (= 7.2.1.1)
activejob (= 7.2.1.1)
activerecord (= 7.2.1.1)
activestorage (= 7.2.1.1)
activesupport (= 7.2.1.1)
actionmailbox (7.2.2)
actionpack (= 7.2.2)
activejob (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
mail (>= 2.8.0)
actionmailer (7.2.1.1)
actionpack (= 7.2.1.1)
actionview (= 7.2.1.1)
activejob (= 7.2.1.1)
activesupport (= 7.2.1.1)
actionmailer (7.2.2)
actionpack (= 7.2.2)
actionview (= 7.2.2)
activejob (= 7.2.2)
activesupport (= 7.2.2)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.1.1)
actionview (= 7.2.1.1)
activesupport (= 7.2.1.1)
actionpack (7.2.2)
actionview (= 7.2.2)
activesupport (= 7.2.2)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.2)
@@ -39,36 +39,37 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.1.1)
actionpack (= 7.2.1.1)
activerecord (= 7.2.1.1)
activestorage (= 7.2.1.1)
activesupport (= 7.2.1.1)
actiontext (7.2.2)
actionpack (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.1.1)
activesupport (= 7.2.1.1)
actionview (7.2.2)
activesupport (= 7.2.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.1.1)
activesupport (= 7.2.1.1)
activejob (7.2.2)
activesupport (= 7.2.2)
globalid (>= 0.3.6)
activemodel (7.2.1.1)
activesupport (= 7.2.1.1)
activerecord (7.2.1.1)
activemodel (= 7.2.1.1)
activesupport (= 7.2.1.1)
activemodel (7.2.2)
activesupport (= 7.2.2)
activerecord (7.2.2)
activemodel (= 7.2.2)
activesupport (= 7.2.2)
timeout (>= 0.4.0)
activestorage (7.2.1.1)
actionpack (= 7.2.1.1)
activejob (= 7.2.1.1)
activerecord (= 7.2.1.1)
activesupport (= 7.2.1.1)
activestorage (7.2.2)
actionpack (= 7.2.2)
activejob (= 7.2.2)
activerecord (= 7.2.2)
activesupport (= 7.2.2)
marcel (~> 1.0)
activesupport (7.2.1.1)
activesupport (7.2.2)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
@@ -99,6 +100,7 @@ GEM
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.3.0)
better_html (2.1.1)
actionview (>= 6.0)
activesupport (>= 6.0)
@@ -131,7 +133,7 @@ GEM
rexml
crass (1.0.6)
csv (3.3.0)
date (3.3.4)
date (3.4.0)
debug (1.9.2)
irb (~> 1.10)
reline (>= 0.3.8)
@@ -188,6 +190,10 @@ GEM
actioncable (>= 6.0.0)
listen (>= 3.0.0)
railties (>= 6.0.0)
hotwire_combobox (0.3.2)
rails (>= 7.0.7.2)
stimulus-rails (>= 1.2)
turbo-rails (>= 1.2)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.14)
@@ -228,7 +234,7 @@ GEM
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.6.1)
loofah (2.22.0)
loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -241,7 +247,7 @@ GEM
mini_magick (4.13.2)
mini_mime (1.1.5)
minitest (5.25.1)
mocha (2.4.5)
mocha (2.5.0)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
multipart-post (2.4.1)
@@ -256,7 +262,7 @@ GEM
timeout
net-smtp (0.5.0)
net-protocol
nio4r (2.7.3)
nio4r (2.7.4)
nokogiri (1.16.7-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.7-arm-linux)
@@ -272,12 +278,12 @@ GEM
octokit (9.2.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (9.1.0)
pagy (9.1.1)
parallel (1.26.3)
parser (3.3.5.0)
ast (~> 2.4.1)
racc
pg (1.5.8)
pg (1.5.9)
prism (1.2.0)
propshaft (1.1.0)
actionpack (>= 7.0.0)
@@ -296,23 +302,22 @@ GEM
rack (>= 3.0.0)
rack-test (2.1.0)
rack (>= 1.3)
rackup (2.1.0)
rackup (2.2.0)
rack (>= 3)
webrick (~> 1.8)
rails (7.2.1.1)
actioncable (= 7.2.1.1)
actionmailbox (= 7.2.1.1)
actionmailer (= 7.2.1.1)
actionpack (= 7.2.1.1)
actiontext (= 7.2.1.1)
actionview (= 7.2.1.1)
activejob (= 7.2.1.1)
activemodel (= 7.2.1.1)
activerecord (= 7.2.1.1)
activestorage (= 7.2.1.1)
activesupport (= 7.2.1.1)
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)
bundler (>= 1.15.0)
railties (= 7.2.1.1)
railties (= 7.2.2)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -326,9 +331,9 @@ GEM
rails-settings-cached (2.9.5)
activerecord (>= 5.0.0)
railties (>= 5.0.0)
railties (7.2.1.1)
actionpack (= 7.2.1.1)
activesupport (= 7.2.1.1)
railties (7.2.2)
actionpack (= 7.2.2)
activesupport (= 7.2.2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -347,7 +352,7 @@ GEM
regexp_parser (2.9.2)
reline (0.5.10)
io-console (~> 0.5)
rexml (3.3.8)
rexml (3.3.9)
rubocop (1.67.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
@@ -381,7 +386,7 @@ GEM
prism (>= 1.2, < 2.0)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.3.20)
ruby-lsp-rails (0.3.21)
ruby-lsp (>= 0.20.0, < 0.21.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.2)
@@ -393,7 +398,7 @@ GEM
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
securerandom (0.3.1)
selenium-webdriver (4.25.0)
selenium-webdriver (4.26.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -412,12 +417,12 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11609)
sorbet-runtime (0.5.11618)
stackprof (0.2.26)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.1)
stripe (13.0.1)
stripe (13.1.0)
tailwindcss-rails (3.0.0)
railties (>= 7.0.0)
tailwindcss-ruby
@@ -450,7 +455,6 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.2)
websocket (1.2.11)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
@@ -485,6 +489,7 @@ DEPENDENCIES
good_job
holidays
hotwire-livereload
hotwire_combobox
i18n-tasks
image_processing (>= 1.2)
importmap-rails
@@ -498,7 +503,7 @@ DEPENDENCIES
pg (~> 1.5)
propshaft
puma (>= 5.0)
rails (~> 7.2.1)
rails (~> 7.2.2)
rails-settings-cached
redcarpet
rubocop-rails-omakase
@@ -518,7 +523,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 3.3.4p94
ruby 3.3.5p100
BUNDLED WITH
2.5.9
2.5.22

View File

@@ -42,7 +42,7 @@ The instructions below are for developers to get started with contributing to th
### Requirements
- Ruby 3.3.4
- See `.ruby-version` file for required Ruby version
- PostgreSQL >9.3 (ideally, latest stable version)
After cloning the repo, the basic setup commands are:

View File

@@ -19,7 +19,8 @@
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
}
.form-field__label {
.form-field__label, .hw-combobox__label {
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
}
@@ -100,7 +101,7 @@
}
.btn {
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer;
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer focus:outline-gray-500;
}
.btn--primary {
@@ -120,6 +121,33 @@
}
}
.combobox {
.hw-combobox__main__wrapper, .hw-combobox__input {
@apply w-full;
}
.hw-combobox__main__wrapper {
@apply border-0 p-0 focus:border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none focus-within:shadow-none;
}
.hw-combobox__listbox {
@apply absolute top-[160%] right-0 w-full bg-transparent rounded z-30;
}
.hw_combobox__pagination__wrapper {
@apply h-px;
&:only-child {
@apply bg-transparent;
}
}
--hw-border-color: rgba(0, 0, 0, 0.2);
--hw-handle-width: 20px;
--hw-handle-height: 20px;
--hw-handle-offset-right: 0px;
}
/* Small, single purpose classes that should take precedence over other styles */
@layer utilities {
.scrollbar::-webkit-scrollbar {

View File

@@ -4,13 +4,21 @@ class Account::EntriesController < ApplicationController
before_action :set_account
before_action :set_entry, only: %i[edit update show destroy]
def index
@q = search_params
@pagy, @entries = pagy(@account.entries.search(@q).reverse_chronological, limit: params[:per_page] || "10")
end
def edit
render entryable_view_path(:edit)
end
def update
prev_amount = @entry.amount
prev_date = @entry.date
@entry.update!(entry_params)
@entry.sync_account_later
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
@@ -43,6 +51,11 @@ class Account::EntriesController < ApplicationController
end
def entry_params
params.require(:account_entry).permit(:name, :date, :amount, :currency)
params.require(:account_entry).permit(:name, :date, :amount, :currency, :notes)
end
def search_params
params.fetch(:q, {})
.permit(:search)
end
end

View File

@@ -17,10 +17,10 @@ class Account::TradesController < ApplicationController
if entry = @builder.save
entry.sync_account_later
redirect_to account_path(@account), notice: t(".success")
redirect_to @account, notice: t(".success")
else
flash[:alert] = t(".failure")
redirect_back_or_to account_path(@account)
redirect_back_or_to @account
end
end
@@ -33,6 +33,13 @@ class Account::TradesController < ApplicationController
end
end
def securities
query = params[:q]
return render json: [] if query.blank? || query.length < 2 || query.length > 100
@securities = Security::SynthComboboxOption.find_in_synth(query)
end
private
def set_account

View File

@@ -12,16 +12,25 @@ class Account::TransactionsController < ApplicationController
end
def update
@entry.update!(entry_params)
prev_amount = @entry.amount
prev_date = @entry.date
@entry.update!(entry_params.except(:origin))
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
@entry,
partial: "account/entries/entry",
locals: entry_locals.merge(entry: @entry)
)
end
end
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
@@ -30,10 +39,18 @@ class Account::TransactionsController < ApplicationController
@entry = @account.entries.find(params[:id])
end
def entry_locals
{
selectable: entry_params[:origin].present?,
show_balance: entry_params[:origin] == "account",
origin: entry_params[:origin]
}
end
def entry_params
params.require(:account_entry)
.permit(
:name, :date, :amount, :currency, :excluded, :notes, :entryable_type, :nature,
:name, :date, :amount, :currency, :excluded, :notes, :entryable_type, :nature, :origin,
entryable_attributes: [
:id,
:category_id,

View File

@@ -1,12 +1,15 @@
class Account::TransfersController < ApplicationController
layout :with_sidebar
before_action :set_transfer, only: :destroy
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])
@@ -14,8 +17,7 @@ class Account::TransfersController < ApplicationController
@transfer = Account::Transfer.build_from_accounts from_account, to_account, \
date: transfer_params[:date],
amount: transfer_params[:amount].to_d,
currency: transfer_params[:currency],
name: transfer_params[:name]
currency: transfer_params[:currency]
if @transfer.save
@transfer.entries.each(&:sync_account_later)
@@ -28,18 +30,33 @@ class Account::TransfersController < ApplicationController
end
end
def update
@transfer.update_entries!(transfer_update_params)
redirect_back_or_to transactions_url, notice: t(".success")
end
def destroy
@transfer.destroy_and_remove_marks!
@transfer.destroy!
redirect_back_or_to transactions_url, notice: t(".success")
end
private
def set_transfer
@transfer = Account::Transfer.find(params[:id])
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)
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

View File

@@ -15,7 +15,7 @@ class Account::ValuationsController < ApplicationController
redirect_back_or_to account_valuations_path(@account), notice: t(".success")
else
flash[:alert] = @entry.errors.full_messages.to_sentence
redirect_to account_path(@account)
redirect_to @account
end
end

View File

@@ -1,8 +1,7 @@
class AccountsController < ApplicationController
layout :with_sidebar
include Filterable
before_action :set_account, only: %i[edit show destroy sync update]
before_action :set_account, only: %i[sync]
def index
@institutions = Current.family.institutions
@@ -10,6 +9,7 @@ class AccountsController < ApplicationController
end
def summary
@period = Period.from_param(params[:period])
snapshot = Current.family.snapshot(@period)
@net_worth_series = snapshot[:net_worth_series]
@asset_series = snapshot[:asset_series]
@@ -19,48 +19,10 @@ class AccountsController < ApplicationController
end
def list
@period = Period.from_param(params[:period])
render layout: false
end
def new
@account = Account.new(currency: Current.family.currency)
@account.accountable = Accountable.from_type(params[:type])&.new if params[:type].present?
@account.accountable.address = Address.new if @account.accountable.is_a?(Property)
if params[:institution_id]
@account.institution = Current.family.institutions.find_by(id: params[:institution_id])
end
end
def show
end
def edit
@account.accountable.build_address if @account.accountable.is_a?(Property) && @account.accountable.address.blank?
end
def update
@account.update_with_sync!(account_params)
redirect_back_or_to account_path(@account), notice: t(".success")
end
def create
@account = Current.family
.accounts
.create_with_optional_start_balance! \
attributes: account_params.except(:start_date, :start_balance),
start_date: account_params[:start_date],
start_balance: account_params[:start_balance]
@account.sync_later
redirect_back_or_to account_path(@account), notice: t(".success")
end
def destroy
@account.destroy!
redirect_to accounts_path, notice: t(".success")
end
def sync
unless @account.syncing?
@account.sync_later
@@ -73,12 +35,7 @@ class AccountsController < ApplicationController
end
private
def set_account
@account = Current.family.accounts.find(params[:id])
end
def account_params
params.require(:account).permit(:name, :accountable_type, :mode, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id)
end
end

View File

@@ -0,0 +1,60 @@
module AccountableResource
extend ActiveSupport::Concern
included do
layout :with_sidebar
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
end
class_methods do
def permitted_accountable_attributes(*attrs)
@permitted_accountable_attributes = attrs if attrs.any?
@permitted_accountable_attributes ||= [ :id ]
end
end
def new
@account = Current.family.accounts.build(
currency: Current.family.currency,
accountable: accountable_type.new,
institution_id: params[:institution_id]
)
end
def show
end
def edit
end
def create
@account = Current.family.accounts.create_and_sync(account_params.except(:return_to))
redirect_to account_params[:return_to].presence || @account, notice: t(".success")
end
def update
@account.update_with_sync!(account_params.except(:return_to))
redirect_back_or_to @account, notice: t(".success")
end
def destroy
@account.destroy!
redirect_to accounts_path, notice: t(".success")
end
private
def accountable_type
controller_name.classify.constantize
end
def set_account
@account = Current.family.accounts.find(params[:id])
end
def account_params
params.require(:account).permit(
:name, :is_active, :balance, :subtype, :currency, :institution_id, :accountable_type, :return_to,
accountable_attributes: self.class.permitted_accountable_attributes
)
end
end

View File

@@ -1,23 +0,0 @@
module Filterable
extend ActiveSupport::Concern
included do
before_action :set_period
end
private
def set_period
@period = Period.find_by_name(params[:period])
if @period.nil?
start_date = params[:start_date].presence&.to_date
end_date = params[:end_date].presence&.to_date
if start_date.is_a?(Date) && end_date.is_a?(Date) && start_date <= end_date
@period = Period.new(name: "custom", date_range: start_date..end_date)
else
params[:period] = "last_30_days"
@period = Period.find_by_name(params[:period])
end
end
end
end

View File

@@ -7,6 +7,7 @@ module Invitable
private
def invite_code_required?
return false if @invitation.present?
self_hosted? ? Setting.require_invite_for_signup : ENV["REQUIRE_INVITE_CODE"] == "true"
end

View File

@@ -1,41 +1,12 @@
class CreditCardsController < ApplicationController
before_action :set_account, only: :update
include AccountableResource
def create
account = Current.family
.accounts
.create_with_optional_start_balance! \
attributes: account_params.except(:start_date, :start_balance),
start_date: account_params[:start_date],
start_balance: account_params[:start_balance]
account.sync_later
redirect_to account, notice: t(".success")
end
def update
@account.update_with_sync!(account_params)
redirect_to @account, notice: t(".success")
end
private
def set_account
@account = Current.family.accounts.find(params[:id])
end
def account_params
params.require(:account)
.permit(
:name, :balance, :institution_id, :mode, :start_date, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:available_credit,
:minimum_payment,
:apr,
:annual_fee,
:expiration_date
]
)
end
permitted_accountable_attributes(
:id,
:available_credit,
:minimum_payment,
:apr,
:annual_fee,
:expiration_date
)
end

View File

@@ -0,0 +1,3 @@
class CryptosController < ApplicationController
include AccountableResource
end

View File

@@ -0,0 +1,3 @@
class DepositoriesController < ApplicationController
include AccountableResource
end

View File

@@ -0,0 +1,3 @@
class InvestmentsController < ApplicationController
include AccountableResource
end

View File

@@ -0,0 +1,42 @@
class InvitationsController < ApplicationController
skip_authentication only: :accept
def new
@invitation = Invitation.new
end
def create
unless Current.user.admin?
flash[:alert] = t(".failure")
redirect_to settings_profile_path
return
end
@invitation = Current.family.invitations.build(invitation_params)
@invitation.inviter = Current.user
if @invitation.save
InvitationMailer.invite_email(@invitation).deliver_later unless self_hosted?
flash[:notice] = t(".success")
else
flash[:alert] = t(".failure")
end
redirect_to settings_profile_path
end
def accept
@invitation = Invitation.find_by!(token: params[:id])
if @invitation.pending?
redirect_to new_registration_path(invitation: @invitation.token)
else
raise ActiveRecord::RecordNotFound
end
end
private
def invitation_params
params.require(:invitation).permit(:email, :role)
end
end

View File

@@ -3,8 +3,9 @@ class Issue::ExchangeRateProviderMissingsController < ApplicationController
def update
Setting.synth_api_key = exchange_rate_params[:synth_api_key]
@issue.issuable.sync_later
redirect_back_or_to account_path(@issue.issuable)
account = @issue.issuable
account.sync_later
redirect_back_or_to account
end
private

View File

@@ -1,39 +1,7 @@
class LoansController < ApplicationController
before_action :set_account, only: :update
include AccountableResource
def create
account = Current.family
.accounts
.create_with_optional_start_balance! \
attributes: account_params.except(:start_date, :start_balance),
start_date: account_params[:start_date],
start_balance: account_params[:start_balance]
account.sync_later
redirect_to account, notice: t(".success")
end
def update
@account.update_with_sync!(account_params)
redirect_to @account, notice: t(".success")
end
private
def set_account
@account = Current.family.accounts.find(params[:id])
end
def account_params
params.require(:account)
.permit(
:name, :balance, :institution_id, :start_date, :mode, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:rate_type,
:interest_rate,
:term_months
]
)
end
permitted_accountable_attributes(
:id, :rate_type, :interest_rate, :term_months
)
end

View File

@@ -1,7 +1,7 @@
class OnboardingsController < ApplicationController
layout "application"
before_action :set_user
before_action :load_invitation
def show
end
@@ -13,7 +13,12 @@ class OnboardingsController < ApplicationController
end
private
def set_user
@user = Current.user
end
def load_invitation
@invitation = Current.family.invitations.accepted.find_by(email: Current.user.email)
end
end

View File

@@ -0,0 +1,3 @@
class OtherAssetsController < ApplicationController
include AccountableResource
end

View File

@@ -0,0 +1,3 @@
class OtherLiabilitiesController < ApplicationController
include AccountableResource
end

View File

@@ -2,9 +2,8 @@ class PagesController < ApplicationController
skip_before_action :authenticate_user!, only: %i[early_access]
layout :with_sidebar, except: %i[early_access]
include Filterable
def dashboard
@period = Period.from_param(params[:period])
snapshot = Current.family.snapshot(@period)
@net_worth_series = snapshot[:net_worth_series]
@asset_series = snapshot[:asset_series]
@@ -20,7 +19,7 @@ class PagesController < ApplicationController
@top_earners = snapshot_account_transactions[:top_earners]
@top_savers = snapshot_account_transactions[:top_savers]
@accounts = Current.family.accounts
@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

View File

@@ -16,7 +16,7 @@ class PasswordResetsController < ApplicationController
).password_reset.deliver_later
end
redirect_to root_path, notice: t(".requested")
redirect_to new_password_reset_path(step: "pending")
end
def edit

View File

@@ -1,40 +1,22 @@
class PropertiesController < ApplicationController
before_action :set_account, only: :update
include AccountableResource
def create
account = Current.family
.accounts
.create_with_optional_start_balance! \
attributes: account_params.except(:start_date, :start_balance),
start_date: account_params[:start_date],
start_balance: account_params[:start_balance]
permitted_accountable_attributes(
:id, :year_built, :area_unit, :area_value,
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
)
account.sync_later
redirect_to account, notice: t(".success")
def new
@account = Current.family.accounts.build(
currency: Current.family.currency,
accountable: Property.new(
address: Address.new
),
institution_id: params[:institution_id]
)
end
def update
@account.update_with_sync!(account_params)
redirect_to @account, notice: t(".success")
def edit
@account.accountable.address ||= Address.new
end
private
def set_account
@account = Current.family.accounts.find(params[:id])
end
def account_params
params.require(:account)
.permit(
:name, :balance, :institution_id, :start_date, :mode, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:year_built,
:area_unit,
:area_value,
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
]
)
end
end

View File

@@ -4,36 +4,49 @@ class RegistrationsController < ApplicationController
layout "auth"
before_action :set_user, only: :create
before_action :set_invitation
before_action :claim_invite_code, only: :create, if: :invite_code_required?
def new
@user = User.new
@user = User.new(email: @invitation&.email)
end
def create
family = Family.new
@user.family = family
@user.role = :admin
if @invitation
@user.family = @invitation.family
@user.role = @invitation.role
@user.email = @invitation.email
else
family = Family.new
@user.family = family
@user.role = :admin
end
if @user.save
Category.create_default_categories(@user.family)
@invitation&.update!(accepted_at: Time.current)
Category.create_default_categories(@user.family) unless @invitation
@session = create_session_for(@user)
flash[:notice] = t(".success")
redirect_to root_path
redirect_to root_path, notice: t(".success")
else
flash[:alert] = t(".failure")
render :new, status: :unprocessable_entity
end
end
private
def set_user
@user = User.new user_params.except(:invite_code)
def set_invitation
token = params[:invitation]
token ||= params[:user][:invitation] if params[:user].present?
@invitation = Invitation.pending.find_by(token: token)
end
def user_params
params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code)
def set_user
@user = User.new user_params.except(:invite_code, :invitation)
end
def user_params(specific_param = nil)
params = self.params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code, :invitation)
specific_param ? params[specific_param] : params
end
def claim_invite_code

View File

@@ -1,5 +1,7 @@
class Settings::ProfilesController < SettingsController
def show
@user = Current.user
@users = Current.family.users.order(:created_at)
@pending_invitations = Current.family.invitations.pending
end
end

View File

@@ -32,11 +32,12 @@ class TransactionsController < ApplicationController
.create!(transaction_entry_params.merge(amount: amount))
@entry.sync_account_later
redirect_back_or_to account_path(@entry.account), notice: t(".success")
redirect_back_or_to @entry.account, notice: t(".success")
end
def bulk_delete
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
destroyed.map(&:account).uniq.each(&:sync_later)
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
end

View File

@@ -1,41 +1,7 @@
class VehiclesController < ApplicationController
before_action :set_account, only: :update
include AccountableResource
def create
account = Current.family
.accounts
.create_with_optional_start_balance! \
attributes: account_params.except(:start_date, :start_balance),
start_date: account_params[:start_date],
start_balance: account_params[:start_balance]
account.sync_later
redirect_to account, notice: t(".success")
end
def update
@account.update_with_sync!(account_params)
redirect_to @account, notice: t(".success")
end
private
def set_account
@account = Current.family.accounts.find(params[:id])
end
def account_params
params.require(:account)
.permit(
:name, :balance, :institution_id, :start_date, :mode, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:make,
:model,
:year,
:mileage_value,
:mileage_unit
]
)
end
permitted_accountable_attributes(
:id, :make, :model, :year, :mileage_value, :mileage_unit
)
end

View File

@@ -12,43 +12,18 @@ module Account::EntriesHelper
transfers.map(&:transfer).uniq
end
def entry_icon(entry, is_oldest: false)
if is_oldest
"keyboard"
elsif entry.trend.direction.up?
"arrow-up"
elsif entry.trend.direction.down?
"arrow-down"
else
"minus"
end
end
def entry_style(entry, is_oldest: false)
color = is_oldest ? "#D444F1" : entry.trend.color
mixed_hex_styles(color)
end
def entry_name(entry)
if entry.account_trade?
trade = entry.account_trade
prefix = trade.sell? ? "Sell " : "Buy "
generated = prefix + "#{trade.qty.abs} shares of #{trade.security.ticker}"
name = entry.name || generated
name
else
entry.name || "Transaction"
end
end
def entries_by_date(entries, selectable: true)
def entries_by_date(entries, selectable: true, totals: false)
entries.group_by(&:date).map do |date, grouped_entries|
content = capture do
yield grouped_entries
# Valuations always go first, then sort by created_at desc
sorted_entries = grouped_entries.sort_by do |entry|
[ entry.account_valuation? ? 0 : 1, -entry.created_at.to_i ]
end
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable: }
content = capture do
yield sorted_entries
end
render partial: "account/entries/entry_group", locals: { date:, entries: sorted_entries, content:, selectable:, totals: }
end.join.html_safe
end

View File

@@ -1,12 +1,25 @@
module AccountsHelper
def permitted_accountable_partial(account, name = nil)
permitted_names = %w[tooltip header tabs form]
folder = account.accountable_type.underscore
name ||= account.accountable_type.underscore
def period_label(period)
return "since account creation" if period.date_range.begin.nil?
start_date, end_date = period.date_range.first, period.date_range.last
raise "Unpermitted accountable partial: #{name}" unless permitted_names.include?(name)
return "Starting from #{start_date.strftime('%b %d, %Y')}" if end_date.nil?
return "Ending at #{end_date.strftime('%b %d, %Y')}" if start_date.nil?
"accounts/accountables/#{folder}/#{name}"
days_apart = (end_date - start_date).to_i
case days_apart
when 1
"vs. yesterday"
when 7
"vs. last week"
when 30, 31
"vs. last month"
when 365, 366
"vs. last year"
else
"from #{start_date.strftime('%b %d, %Y')} to #{end_date.strftime('%b %d, %Y')}"
end
end
def summary_card(title:, &block)
@@ -38,64 +51,8 @@ module AccountsHelper
class_mapping(accountable_type)[:hex]
end
# Eventually, we'll have an accountable form for each type of accountable, so
# this helper is a convenience for now to reuse common logic in the accounts controller
def new_account_form_url(account)
case account.accountable_type
when "Property"
properties_path
when "Vehicle"
vehicles_path
when "Loan"
loans_path
when "CreditCard"
credit_cards_path
else
accounts_path
end
end
def edit_account_form_url(account)
case account.accountable_type
when "Property"
property_path(account)
when "Vehicle"
vehicle_path(account)
when "Loan"
loan_path(account)
when "CreditCard"
credit_card_path(account)
else
account_path(account)
end
end
def account_tabs(account)
overview_tab = { key: "overview", label: t("accounts.show.overview"), path: account_path(account, tab: "overview"), partial_path: "accounts/overview" }
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), route: account_holdings_path(account) }
cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), route: account_cashes_path(account) }
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), route: account_valuations_path(account) }
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), route: account_transactions_path(account) }
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), route: account_trades_path(account) }
return [ value_tab ] if account.other_asset? || account.other_liability?
return [ overview_tab, value_tab ] if account.property? || account.vehicle?
return [ holdings_tab, cash_tab, trades_tab, value_tab ] if account.investment?
return [ overview_tab, value_tab, transactions_tab ] if account.loan? || account.credit_card?
[ value_tab, transactions_tab ]
end
def selected_account_tab(account)
available_tabs = account_tabs(account)
tab = available_tabs.find { |tab| tab[:key] == params[:tab] }
tab || available_tabs.first
end
def account_groups(period: nil)
assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
assets, liabilities = Current.family.accounts.active.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
[ assets.children.sort_by(&:name), liabilities.children.sort_by(&:name) ].flatten
end

View File

@@ -122,29 +122,6 @@ module ApplicationHelper
{ bg_class: bg_class, text_class: text_class, symbol: symbol, icon: icon }
end
def period_label(period)
return "since account creation" if period.date_range.begin.nil?
start_date, end_date = period.date_range.first, period.date_range.last
return "Starting from #{start_date.strftime('%b %d, %Y')}" if end_date.nil?
return "Ending at #{end_date.strftime('%b %d, %Y')}" if start_date.nil?
days_apart = (end_date - start_date).to_i
case days_apart
when 1
"vs. yesterday"
when 7
"vs. last week"
when 30, 31
"vs. last month"
when 365, 366
"vs. last year"
else
"from #{start_date.strftime('%b %d, %Y')} to #{end_date.strftime('%b %d, %Y')}"
end
end
# Wrapper around I18n.l to support custom date formats
def format_date(object, format = :default, options = {})
date = object.to_date

View File

@@ -0,0 +1,2 @@
module InvitationsHelper
end

View File

@@ -10,7 +10,8 @@ export default class extends Controller {
"bulkEditDrawerTitle",
];
static values = {
resource: String,
singularLabel: String,
pluralLabel: String,
selectedIds: { type: Array, default: [] },
};
@@ -126,15 +127,17 @@ export default class extends Controller {
_updateSelectionBar() {
const count = this.selectedIdsValue.length;
this.selectionBarTextTarget.innerText = `${count} ${this._pluralizedResourceName()} selected`;
this.selectionBarTarget.hidden = count === 0;
this.selectionBarTarget.classList.toggle("hidden", count === 0);
this.selectionBarTarget.querySelector("input[type='checkbox']").checked =
count > 0;
}
_pluralizedResourceName() {
return `${this.resourceValue}${
this.selectedIdsValue.length === 1 ? "" : "s"
}`;
if (this.selectedIdsValue.length === 1) {
return this.singularLabelValue;
}
return this.pluralLabelValue;
}
_updateGroups() {

View File

@@ -535,7 +535,7 @@ export default class extends Controller {
}
get _d3YScale() {
const reductionPercent = this.useLabelsValue ? 0.15 : 0.05;
const reductionPercent = this.useLabelsValue ? 0.3 : 0.05;
const dataMin = d3.min(this._normalDataPoints, (d) => d.value);
const dataMax = d3.max(this._normalDataPoints, (d) => d.value);
const padding = (dataMax - dataMin) * reductionPercent;

View File

@@ -0,0 +1,18 @@
class FetchSecurityInfoJob < ApplicationJob
queue_as :default
def perform(security_id)
return unless Security.security_info_provider.present?
security = Security.find(security_id)
security_info_response = Security.security_info_provider.fetch_security_info(
ticker: security.ticker,
mic_code: security.exchange_mic
)
security.update(
name: security_info_response.info.dig("name")
)
end
end

View File

@@ -1,13 +0,0 @@
class SecuritiesImportJob < ApplicationJob
queue_as :default
def perform(country_code = nil)
exchanges = StockExchange.in_country(country_code)
market_stack_client = Provider::Marketstack.new(ENV["MARKETSTACK_API_KEY"])
exchanges.each do |exchange|
importer = Security::Importer.new(market_stack_client, exchange.mic)
importer.import
end
end
end

View File

@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: ENV["EMAIL_SENDER"] if ENV["EMAIL_SENDER"].present?
default from: email_address_with_name(ENV.fetch("EMAIL_SENDER", "sender@maybe.local"), "Maybe Finance")
layout "mailer"
end

View File

@@ -0,0 +1,11 @@
class InvitationMailer < ApplicationMailer
def invite_email(invitation)
@invitation = invitation
@accept_url = accept_invitation_url(@invitation.token)
mail(
to: @invitation.email,
subject: t(".subject", inviter: @invitation.inviter.display_name)
)
end
end

View File

@@ -1,10 +1,7 @@
class Account < ApplicationRecord
VALUE_MODES = %w[balance transactions]
include Syncable, Monetizable, Issuable
validates :name, :balance, :currency, presence: true
validates :mode, inclusion: { in: VALUE_MODES }, allow_nil: true
belongs_to :family
belongs_to :institution, optional: true
@@ -34,7 +31,7 @@ class Account < ApplicationRecord
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
accepts_nested_attributes_for :accountable
accepts_nested_attributes_for :accountable, update_only: true
delegate :value, :series, to: :accountable
@@ -61,29 +58,32 @@ class Account < ApplicationRecord
grouped_accounts
end
def create_with_optional_start_balance!(attributes:, start_date: nil, start_balance: nil)
transaction do
attributes[:accountable_attributes] ||= {} # Ensure accountable is created
account = new(attributes)
def create_and_sync(attributes)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
account = new(attributes)
# Always initialize an account with a valuation entry to begin tracking value history
account.entries.build \
transaction do
# Create 2 valuations for new accounts to establish a value history for users to see
account.entries.build(
name: "Current Balance",
date: Date.current,
amount: account.balance,
currency: account.currency,
entryable: Account::Valuation.new
if start_date.present? && start_balance.present?
account.entries.build \
date: start_date,
amount: start_balance,
currency: account.currency,
entryable: Account::Valuation.new
end
)
account.entries.build(
name: "Initial Balance",
date: 1.day.ago.to_date,
amount: 0,
currency: account.currency,
entryable: Account::Valuation.new
)
account.save!
account
end
account.sync_later
account
end
end

View File

@@ -46,22 +46,42 @@ class Account::Entry < ApplicationRecord
amount > 0 && account_transaction?
end
def first_of_type?
first_entry = account
.entries
.where("entryable_type = ?", entryable_type)
.order(:date)
.first
first_entry&.id == id
end
def entryable_name_short
entryable_type.demodulize.underscore
end
def prior_balance
account.balances.find_by(date: date - 1)&.balance || 0
end
def balance_after_entry
if account_valuation?
Money.new(amount, currency)
else
new_balance = prior_balance
entries_on_entry_date.each do |e|
next if e.account_valuation?
change = e.amount
change = account.liability? ? change : -change
new_balance += change
break if e == self
end
Money.new(new_balance, currency)
end
end
def trend
@trend ||= create_trend
TimeSeries::Trend.new(
current: balance_after_entry,
previous: Money.new(prior_balance, currency),
favorable_direction: account.favorable_direction
)
end
def entries_on_entry_date
account.entries.where(date: date).order(created_at: :asc)
end
class << self
@@ -216,11 +236,4 @@ class Account::Entry < ApplicationRecord
.order(date: :desc)
.first
end
def create_trend
TimeSeries::Trend.new \
current: amount_money,
previous: previous_entry&.amount_money,
favorable_direction: account.favorable_direction
end
end

View File

@@ -38,23 +38,31 @@ class Account::Holding::Syncer
def security_prices
@security_prices ||= begin
prices = {}
ticker_start_dates = {}
prices = {}
ticker_securities = {}
sync_entries.each do |entry|
unless ticker_start_dates[entry.account_trade.security.ticker]
ticker_start_dates[entry.account_trade.security.ticker] = entry.date
end
end
sync_entries.each do |entry|
security = entry.account_trade.security
unless ticker_securities[security.ticker]
ticker_securities[security.ticker] = {
security: security,
start_date: entry.date
}
end
end
ticker_start_dates.each do |ticker, date|
fetched_prices = Security::Price.find_prices(ticker: ticker, start_date: date, end_date: Date.current)
gapfilled_prices = Gapfiller.new(fetched_prices, start_date: date, end_date: Date.current, cache: false).run
prices[ticker] = gapfilled_prices
end
ticker_securities.each do |ticker, data|
fetched_prices = Security::Price.find_prices(
security: data[:security],
start_date: data[:start_date],
end_date: Date.current
)
gapfilled_prices = Gapfiller.new(fetched_prices, start_date: data[:start_date], end_date: Date.current, cache: false).run
prices[ticker] = gapfilled_prices
end
prices
end
prices
end
end
def build_holdings_for_date(date)
@@ -68,8 +76,6 @@ class Account::Holding::Syncer
price = get_cached_price(ticker, date) || trade_price
account.observe_missing_price(ticker:, date:) unless price
account.holdings.build \
date: date,
security_id: holding[:security_id],

View File

@@ -26,6 +26,12 @@ class Account::Trade < ApplicationRecord
qty > 0
end
def name
prefix = sell? ? "Sell " : "Buy "
generated = prefix + "#{qty.abs} shares of #{security.ticker}"
entry.name || generated
end
def unrealized_gain_loss
return nil if sell?
current_price = security.current_price

View File

@@ -31,7 +31,14 @@ class Account::TradeBuilder < Account::EntryBuilder
end
def security
Security.find_or_create_by(ticker: ticker)
ticker_symbol, exchange_mic, exchange_acronym, exchange_country_code = ticker.split("|")
security = Security.find_or_create_by(ticker: ticker_symbol, exchange_mic: exchange_mic, country_code: exchange_country_code)
security.update(exchange_acronym: exchange_acronym)
FetchSecurityInfoJob.perform_later(security.id)
security
end
def amount

View File

@@ -48,12 +48,20 @@ class Account::Transaction < ApplicationRecord
end
end
def name
entry.name || "(no description)"
end
def eod_balance
entry.amount_money
end
private
def previous_transaction_date
self.account
.transactions
.where("date < ?", date)
.order(date: :desc)
.first&.date
def account
entry.account
end
def daily_transactions
account.entries.account_transactions
end
end

View File

@@ -1,5 +1,5 @@
class Account::Transfer < ApplicationRecord
has_many :entries, dependent: :nullify
has_many :entries, dependent: :destroy
validate :net_zero_flows, if: :single_currency_transfer?
validate :transaction_count, :from_different_accounts, :all_transactions_marked
@@ -13,17 +13,25 @@ class Account::Transfer < ApplicationRecord
end
def from_name
outflow_transaction&.account&.name || I18n.t("account/transfer.from_fallback_name")
from_account&.name || I18n.t("account/transfer.from_fallback_name")
end
def to_name
inflow_transaction&.account&.name || I18n.t("account/transfer.to_fallback_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
@@ -32,23 +40,21 @@ class Account::Transfer < ApplicationRecord
entries.find { |e| e.outflow? }
end
def destroy_and_remove_marks!
def update_entries!(params)
transaction do
entries.each do |e|
e.update! marked_as_transfer: false
entries.each do |entry|
entry.update!(params)
end
destroy!
end
end
class << self
def build_from_accounts(from_account, to_account, date:, amount:, currency:, name:)
def build_from_accounts(from_account, to_account, date:, amount:, currency:)
outflow = from_account.entries.build \
amount: amount.abs,
currency: from_account.currency,
date: date,
name: name,
name: "Transfer to #{to_account.name}",
marked_as_transfer: true,
entryable: Account::Transaction.new
@@ -56,7 +62,7 @@ class Account::Transfer < ApplicationRecord
amount: amount.abs * -1,
currency: from_account.currency,
date: date,
name: name,
name: "Transfer from #{from_account.name}",
marked_as_transfer: true,
entryable: Account::Transaction.new

View File

@@ -10,4 +10,44 @@ class Account::Valuation < ApplicationRecord
false
end
end
def name
oldest? ? "Initial balance" : entry.name || "Balance update"
end
def trend
@trend ||= create_trend
end
def icon
oldest? ? "plus" : entry.trend.icon
end
def color
oldest? ? "#D444F1" : entry.trend.color
end
private
def oldest?
@oldest ||= account.entries.where("date < ?", entry.date).empty?
end
def account
@account ||= entry.account
end
def create_trend
TimeSeries::Trend.new(
current: entry.amount_money,
previous: prior_balance&.balance_money,
favorable_direction: account.favorable_direction
)
end
def prior_balance
@prior_balance ||= account.balances
.where("date < ?", entry.date)
.order(date: :desc)
.first
end
end

View File

@@ -33,8 +33,4 @@ module Accountable
rescue Money::ConversionError
TimeSeries.new([])
end
def mode_required?
true
end
end

View File

@@ -33,12 +33,6 @@ module Issuable
)
end
def observe_missing_price(ticker:, date:)
issue = issues.find_or_create_by(type: Issue::PricesMissing.name, resolved_at: nil)
issue.append_missing_price(ticker, date)
issue.save!
end
def highest_priority_issue
issues.active.ordered.first
end

View File

@@ -10,6 +10,10 @@ module Providable
synth_provider
end
def security_info_provider
synth_provider
end
def exchange_rates_provider
synth_provider
end

View File

@@ -16,4 +16,8 @@ class CreditCard < ApplicationRecord
def color
"#F13636"
end
def icon
"credit-card"
end
end

View File

@@ -4,4 +4,8 @@ class Crypto < ApplicationRecord
def color
"#737373"
end
def icon
"bitcoin"
end
end

View File

@@ -34,6 +34,7 @@ class Demo::Generator
create_investment_account!
create_house_and_mortgage!
create_car_and_loan!
create_other_accounts!
puts "accounts created"
puts "Demo data loaded successfully!"
@@ -50,7 +51,7 @@ class Demo::Generator
family = Family.find_by(id: family_id)
family.destroy! if family
Family.create!(id: family_id, name: "Demo Family").tap(&:reload)
Family.create!(id: family_id, name: "Demo Family", stripe_subscription_status: "active").tap(&:reload)
end
def clear_data!
@@ -176,12 +177,12 @@ class Demo::Generator
def load_securities!
# Create an unknown security to simulate edge cases
Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock"
Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock", exchange_mic: "UNKNOWN"
securities = [
{ ticker: "AAPL", name: "Apple Inc.", reference_price: 210 },
{ ticker: "TM", name: "Toyota Motor Corporation", reference_price: 202 },
{ ticker: "MSFT", name: "Microsoft Corporation", reference_price: 455 }
{ ticker: "AAPL", exchange_mic: "NASDAQ", name: "Apple Inc.", reference_price: 210 },
{ ticker: "TM", exchange_mic: "NYSE", name: "Toyota Motor Corporation", reference_price: 202 },
{ ticker: "MSFT", exchange_mic: "NASDAQ", name: "Microsoft Corporation", reference_price: 455 }
]
securities.each do |security_attributes|
@@ -193,7 +194,7 @@ class Demo::Generator
low_price = reference - 20
high_price = reference + 20
Security::Price.create! \
ticker: security.ticker,
security: security,
date: date,
price: Faker::Number.positive(from: low_price, to: high_price)
end
@@ -273,6 +274,20 @@ class Demo::Generator
currency: "USD"
end
def create_other_accounts!
family.accounts.create! \
accountable: OtherAsset.new,
name: "Other Asset",
balance: 10000,
currency: "USD"
family.accounts.create! \
accountable: OtherLiability.new,
name: "Other Liability",
balance: 5000,
currency: "USD"
end
def create_transaction!(attributes = {})
entry_attributes = attributes.except(:category, :tags, :merchant)
transaction_attributes = attributes.slice(:category, :tags, :merchant)

View File

@@ -1,7 +1,16 @@
class Depository < ApplicationRecord
include Accountable
SUBTYPES = [
[ "Checking", "checking" ],
[ "Savings", "savings" ]
].freeze
def color
"#875BF7"
end
def icon
"landmark"
end
end

View File

@@ -4,6 +4,7 @@ class Family < ApplicationRecord
include Providable
has_many :users, dependent: :destroy
has_many :invitations, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :institutions, dependent: :destroy
@@ -71,7 +72,9 @@ class Family < ApplicationRecord
end
def snapshot_transactions
candidate_entries = entries.account_transactions.without_transfers
candidate_entries = entries.account_transactions.without_transfers.excluding(
entries.joins(:account).where(amount: ..0, accounts: { classification: Account.classifications[:liability] })
)
rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days)
spending = []

View File

@@ -50,4 +50,8 @@ class Investment < ApplicationRecord
def color
"#1570EF"
end
def icon
"line-chart"
end
end

37
app/models/invitation.rb Normal file
View File

@@ -0,0 +1,37 @@
class Invitation < ApplicationRecord
belongs_to :family
belongs_to :inviter, class_name: "User"
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :role, presence: true, inclusion: { in: %w[admin member] }
validates :token, presence: true, uniqueness: true
validates_uniqueness_of :email, scope: :family_id, message: "has already been invited to this family"
validate :inviter_is_admin
before_validation :generate_token, on: :create
before_create :set_expiration
scope :pending, -> { where(accepted_at: nil).where("expires_at > ?", Time.current) }
scope :accepted, -> { where.not(accepted_at: nil) }
def pending?
accepted_at.nil? && expires_at > Time.current
end
private
def generate_token
loop do
self.token = SecureRandom.hex(32)
break unless self.class.exists?(token: token)
end
end
def set_expiration
self.expires_at = 3.days.from_now
end
def inviter_is_admin
inviter.admin?
end
end

View File

@@ -1,33 +0,0 @@
class Issue::PricesMissing < Issue
store_accessor :data, :missing_prices
after_initialize :initialize_missing_prices
validates :missing_prices, presence: true, allow_blank: true
def append_missing_price(ticker, date)
missing_prices[ticker] ||= []
missing_prices[ticker] << date unless missing_prices[ticker].include?(date.to_s)
end
def stale?
stale = true
missing_prices.each do |ticker, dates|
next unless issuable.owns_ticker?(ticker)
oldest_date = dates.min.to_date
expected_price_count = (oldest_date..Date.current).count
prices = Security::Price.find_prices(ticker: ticker, start_date: oldest_date)
stale = false if prices.count < expected_price_count
end
stale
end
private
def initialize_missing_prices
self.missing_prices ||= {}
end
end

View File

@@ -20,4 +20,8 @@ class Loan < ApplicationRecord
def color
"#D444F1"
end
def icon
"hand-coins"
end
end

View File

@@ -5,7 +5,7 @@ class OtherAsset < ApplicationRecord
"#12B76A"
end
def mode_required?
false
def icon
"plus"
end
end

View File

@@ -5,7 +5,7 @@ class OtherLiability < ApplicationRecord
"#737373"
end
def mode_required?
false
def icon
"minus"
end
end

View File

@@ -1,12 +1,18 @@
class Period
attr_reader :name, :date_range
def self.find_by_name(name)
INDEX[name]
end
class << self
def from_param(param)
find_by_name(param) || self.last_30_days
end
def self.names
INDEX.keys.sort
def find_by_name(name)
INDEX[name]
end
def names
INDEX.keys.sort
end
end
def initialize(name: "custom", date_range:)

View File

@@ -1,6 +1,14 @@
class Property < ApplicationRecord
include Accountable
SUBTYPES = [
[ "Single Family Home", "single_family_home" ],
[ "Multi-Family Home", "multi_family_home" ],
[ "Condominium", "condominium" ],
[ "Townhouse", "townhouse" ],
[ "Investment Property", "investment_property" ]
]
has_one :address, as: :addressable, dependent: :destroy
accepts_nested_attributes_for :address
@@ -23,8 +31,8 @@ class Property < ApplicationRecord
"#06AED4"
end
def mode_required?
false
def icon
"home"
end
private

View File

@@ -1,119 +0,0 @@
class Provider::Marketstack
include Retryable
def initialize(api_key)
@api_key = api_key
end
def fetch_security_prices(ticker:, start_date:, end_date:)
prices = paginate("#{base_url}/eod", {
symbols: ticker,
date_from: start_date.to_s,
date_to: end_date.to_s
}) do |body|
body.dig("data").map do |price|
{
date: price["date"],
price: price["close"]&.to_f,
currency: "USD"
}
end
end
SecurityPriceResponse.new(
prices: prices,
success?: true,
raw_response: prices.to_json
)
rescue StandardError => error
SecurityPriceResponse.new(
success?: false,
error: error,
raw_response: error
)
end
def fetch_tickers(exchange_mic: nil)
url = exchange_mic ? "#{base_url}/tickers?exchange=#{exchange_mic}" : "#{base_url}/tickers"
tickers = paginate(url) do |body|
body.dig("data").map do |ticker|
{
name: ticker["name"],
symbol: ticker["symbol"],
exchange: exchange_mic || ticker.dig("stock_exchange", "mic"),
country_code: ticker.dig("stock_exchange", "country_code")
}
end
end
TickerResponse.new(
tickers: tickers,
success?: true,
raw_response: tickers.to_json
)
rescue StandardError => error
TickerResponse.new(
success?: false,
error: error,
raw_response: error
)
end
private
attr_reader :api_key
SecurityPriceResponse = Struct.new(:prices, :success?, :error, :raw_response, keyword_init: true)
TickerResponse = Struct.new(:tickers, :success?, :error, :raw_response, keyword_init: true)
def base_url
"https://api.marketstack.com/v1"
end
def client
@client ||= Faraday.new(url: base_url) do |faraday|
faraday.params["access_key"] = api_key
end
end
def build_error(response)
Provider::Base::ProviderError.new(<<~ERROR)
Failed to fetch data from #{self.class}
Status: #{response.status}
Body: #{response.body.inspect}
ERROR
end
def fetch_page(url, page, params = {})
client.get(url) do |req|
params.each { |k, v| req.params[k.to_s] = v.to_s }
req.params["offset"] = (page - 1) * 100 # Marketstack uses offset-based pagination
req.params["limit"] = 10000 # Maximum allowed by Marketstack
end
end
def paginate(url, params = {})
results = []
page = 1
total_results = Float::INFINITY
while results.length < total_results
response = fetch_page(url, page, params)
if response.success?
body = JSON.parse(response.body)
page_results = yield(body)
results.concat(page_results)
total_results = body.dig("pagination", "total")
page += 1
else
raise build_error(response)
end
break if results.length >= total_results
end
results
end
end

View File

@@ -9,6 +9,7 @@ class Provider::Synth
response = client.get("#{base_url}/user")
JSON.parse(response.body).dig("id").present?
end
def usage
response = client.get("#{base_url}/user")
@@ -42,9 +43,10 @@ class Provider::Synth
)
end
def fetch_security_prices(ticker:, start_date:, end_date:)
def fetch_security_prices(ticker:, mic_code:, start_date:, end_date:)
prices = paginate(
"#{base_url}/tickers/#{ticker}/open-close",
mic_code: mic_code,
start_date: start_date,
end_date: end_date
) do |body|
@@ -121,6 +123,45 @@ class Provider::Synth
raw_response: error
end
def search_securities(query:, dataset: "limited", country_code:)
response = client.get("#{base_url}/tickers/search") do |req|
req.params["name"] = query
req.params["dataset"] = dataset
req.params["country_code"] = country_code
end
parsed = JSON.parse(response.body)
securities = parsed.dig("data").map do |security|
{
symbol: security.dig("symbol"),
name: security.dig("name"),
logo_url: security.dig("logo_url"),
exchange_acronym: security.dig("exchange", "acronym"),
exchange_mic: security.dig("exchange", "mic_code"),
exchange_country_code: security.dig("exchange", "country_code")
}
end
SearchSecuritiesResponse.new \
securities: securities,
success?: true,
raw_response: response
end
def fetch_security_info(ticker:, mic_code:)
response = client.get("#{base_url}/tickers/#{ticker}") do |req|
req.params["mic_code"] = mic_code
end
parsed = JSON.parse(response.body)
SecurityInfoResponse.new \
info: parsed.dig("data"),
success?: true,
raw_response: response
end
private
attr_reader :api_key
@@ -129,6 +170,8 @@ class Provider::Synth
SecurityPriceResponse = Struct.new :prices, :success?, :error, :raw_response, keyword_init: true
ExchangeRatesResponse = Struct.new :rates, :success?, :error, :raw_response, keyword_init: true
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
def base_url
"https://api.synthfinance.com"

View File

@@ -1,16 +1,24 @@
class Security < ApplicationRecord
include Providable
before_save :upcase_ticker
has_many :trades, dependent: :nullify, class_name: "Account::Trade"
has_many :prices, dependent: :destroy
validates :ticker, presence: true, uniqueness: { case_sensitive: false }
validates :ticker, presence: true
validates :ticker, uniqueness: { scope: :exchange_mic, case_sensitive: false }
def current_price
@current_price ||= Security::Price.find_price(ticker:, date: Date.current)
@current_price ||= Security::Price.find_price(security: self, date: Date.current)
return nil if @current_price.nil?
Money.new(@current_price.price, @current_price.currency)
end
def to_combobox_display
"#{ticker} (#{exchange_acronym})"
end
private
def upcase_ticker

View File

@@ -1,27 +0,0 @@
class Security::Importer
def initialize(provider, stock_exchange = nil)
@provider = provider
@stock_exchange = stock_exchange
end
def import
securities = @provider.fetch_tickers(exchange_mic: @stock_exchange)&.tickers
stock_exchanges = StockExchange.where(mic: securities.map { |s| s[:exchange] }).index_by(&:mic)
existing_securities = Security.where(ticker: securities.map { |s| s[:symbol] }, stock_exchange_id: stock_exchanges.values.map(&:id)).pluck(:ticker, :stock_exchange_id).to_set
securities_to_create = securities.map do |security|
stock_exchange_id = stock_exchanges[security[:exchange]]&.id
next if existing_securities.include?([ security[:symbol], stock_exchange_id ])
{
name: security[:name],
ticker: security[:symbol],
stock_exchange_id: stock_exchange_id,
country_code: security[:country_code]
}
end.compact
Security.insert_all(securities_to_create) unless securities_to_create.empty?
end
end

View File

@@ -1,33 +1,33 @@
class Security::Price < ApplicationRecord
include Provided
before_save :upcase_ticker
belongs_to :security
validates :ticker, presence: true, uniqueness: { scope: :date, case_sensitive: false }
validates :price, :currency, presence: true
class << self
def find_price(ticker:, date:, cache: true)
result = find_by(ticker:, date:)
def find_price(security:, date:, cache: true)
result = find_by(security:, date:)
result || fetch_price_from_provider(ticker:, date:, cache:)
result || fetch_price_from_provider(security:, date:, cache:)
end
def find_prices(ticker:, start_date:, end_date: Date.current, cache: true)
prices = where(ticker:, date: start_date..end_date).to_a
def find_prices(security:, start_date:, end_date: Date.current, cache: true)
prices = where(security_id: security.id, date: start_date..end_date).to_a
all_dates = (start_date..end_date).to_a.to_set
existing_dates = prices.map(&:date).to_set
missing_dates = (all_dates - existing_dates).sort
if missing_dates.any?
prices += fetch_prices_from_provider(ticker:, start_date: missing_dates.first, end_date: missing_dates.last, cache:)
prices += fetch_prices_from_provider(
security: security,
start_date: missing_dates.first,
end_date: missing_dates.last,
cache: cache
)
end
prices
end
end
private
def upcase_ticker
self.ticker = ticker.upcase
end
end

View File

@@ -6,17 +6,18 @@ module Security::Price::Provided
class_methods do
private
def fetch_price_from_provider(ticker:, date:, cache: false)
def fetch_price_from_provider(security:, date:, cache: false)
return nil unless security_prices_provider.present?
response = security_prices_provider.fetch_security_prices \
ticker: ticker,
ticker: security.ticker,
mic_code: security.exchange_mic,
start_date: date,
end_date: date
if response.success? && response.prices.size > 0
price = Security::Price.new \
ticker: ticker,
security: security,
date: response.prices.first[:date],
price: response.prices.first[:price],
currency: response.prices.first[:currency]
@@ -28,18 +29,20 @@ module Security::Price::Provided
end
end
def fetch_prices_from_provider(ticker:, start_date:, end_date:, cache: false)
def fetch_prices_from_provider(security:, start_date:, end_date:, cache: false)
return [] unless security_prices_provider.present?
return [] unless security
response = security_prices_provider.fetch_security_prices \
ticker: ticker,
ticker: security.ticker,
mic_code: security.exchange_mic,
start_date: start_date,
end_date: end_date
if response.success?
response.prices.map do |price|
new_price = Security::Price.find_or_initialize_by(
ticker: ticker,
security: security,
date: price[:date]
) do |p|
p.price = price[:price]

View File

@@ -0,0 +1,27 @@
class Security::SynthComboboxOption
include ActiveModel::Model
include Providable
attr_accessor :symbol, :name, :logo_url, :exchange_acronym, :exchange_mic, :exchange_country_code
class << self
def find_in_synth(query)
country = Current.family.country
country = "#{country},US" unless country == "US"
security_prices_provider.search_securities(
query:,
dataset: "limited",
country_code: country
).securities.map { |attrs| new(**attrs) }
end
end
def id
"#{symbol}|#{exchange_mic}|#{exchange_acronym}|#{exchange_country_code}" # submitted by combobox as value
end
def to_combobox_display
"#{symbol} - #{name} (#{exchange_acronym})" # shown in combobox input when selected
end
end

View File

@@ -35,9 +35,19 @@ class TimeSeries::Trend
end
end
def icon
if direction.flat?
"minus"
elsif direction.up?
"arrow-up"
else
"arrow-down"
end
end
def value
if previous.nil?
current.is_a?(Money) ? Money.new(0) : 0
current.is_a?(Money) ? Money.new(0, current.currency) : 0
else
current - previous
end

View File

@@ -30,6 +30,10 @@ class User < ApplicationRecord
impersonator_support_sessions.create!(impersonated: impersonated)
end
def admin?
super_admin? || role == "admin"
end
def display_name
[ first_name, last_name ].compact.join(" ").presence || email
end

View File

@@ -19,8 +19,8 @@ class Vehicle < ApplicationRecord
"#F23E94"
end
def mode_required?
false
def icon
"car-front"
end
private

View File

@@ -1,5 +1,5 @@
<%# locals: (entry:, **opts) %>
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
<%= turbo_frame_tag dom_id(entry) do %>
<%= render partial: entry.entryable.to_partial_path, locals: { entry: entry, **opts } %>
<%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, show_balance:, origin: } %>
<% end %>

View File

@@ -1,4 +1,4 @@
<%# locals: (date:, entries:, content:, selectable:) %>
<%# locals: (date:, entries:, content:, selectable:, totals: false) %>
<div id="entry-group-<%= date %>" class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
<div class="flex pl-0.5 items-center gap-4">
@@ -16,7 +16,9 @@
</p>
</div>
<%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %>
<% if totals %>
<%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %>
<% end %>
</div>
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
<%= content %>

View File

@@ -0,0 +1,15 @@
<div class="fixed bottom-6 z-10 flex items-center justify-between rounded-xl bg-gray-900 px-4 text-sm text-white w-[420px] py-1.5">
<div class="flex items-center gap-2">
<%= check_box_tag "entry_selection", 1, true, class: "maybe-checkbox maybe-checkbox--dark", data: { action: "bulk-select#deselectAll" } %>
<p data-bulk-select-target="selectionBarText"></p>
</div>
<div class="flex items-center gap-1 text-gray-500">
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
</button>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,88 @@
<%= 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" %>
<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), 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 %>
<%= link_to @account.investment? ? new_account_trade_path(@account) : new_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 %>
</div>
</div>
</div>
<% if @entries.empty? %>
<p class="text-gray-500 text-sm p-4"><%= t(".no_entries") %></p>
<% else %>
<div>
<%= form_with url: account_entries_path(@account),
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") %>
<%= 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>
<%= 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">
<%= entries_by_date(@entries) do |entries| %>
<%= render entries, show_balance: true, origin: "account" %>
<% 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 %>

View File

@@ -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">
<%= render "shared/circle_logo", name: holding.name %>
<%= image_tag "https://logo.synthfinance.com/ticker/#{holding.ticker}", class: "w-9 h-9 rounded-full" %>
<div class="space-y-0.5">
<%= link_to holding.name, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>

View File

@@ -6,7 +6,7 @@
<%= tag.p @holding.ticker, class: "text-sm text-gray-500" %>
</div>
<%= render "shared/circle_logo", name: @holding.name %>
<%= image_tag "https://logo.synthfinance.com/ticker/#{@holding.ticker}", class: "w-9 h-9 rounded-full" %>
</header>
<details class="group space-y-2" open>

View File

@@ -7,10 +7,12 @@
<div class="space-y-2">
<%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell], %w[Deposit transfer_in], %w[Withdrawal transfer_out], %w[Interest interest]], "buy"), { label: t(".type") }, { data: { "trade-form-target": "typeInput" } } %>
<div data-trade-form-target="tickerInput">
<%= form.text_field :ticker, value: nil, label: t(".holding"), placeholder: t(".ticker_placeholder") %>
<div class="form-field combobox">
<%= form.combobox :ticker, securities_account_trades_path(entry.account), label: t(".holding"), placeholder: t(".ticker_placeholder") %>
</div>
</div>
<%= form.date_field :date, label: true %>
<%= form.date_field :date, label: true, value: Date.today %>
<div data-trade-form-target="amountInput" hidden>
<%= form.money_field :amount, label: t(".amount"), disable_currency: true %>

View File

@@ -0,0 +1,11 @@
<div class="flex items-center">
<%= image_tag(security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %>
<div class="flex flex-col">
<span class="text-sm font-medium">
<%= security.name.presence || security.symbol %>
</span>
<span class="text-xs text-gray-500">
<%= "#{security.symbol} (#{security.exchange_acronym})" %>
</span>
</div>
</div>

View File

@@ -1,4 +1,4 @@
<%# locals: (entry:, selectable: true, **opts) %>
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
<% trade, account = entry.account_trade, entry.account %>
@@ -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">
<%= entry_name(entry).first.upcase %>
<%= trade.name.first.upcase %>
</div>
<div class="truncate text-gray-900">
<% if entry.new_record? %>
<%= content_tag :p, entry_name(entry) %>
<%= content_tag :p, trade.name %>
<% else %>
<%= link_to entry_name(entry),
<%= link_to trade.name,
account_entry_path(account, entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>

View File

@@ -24,7 +24,7 @@
</div>
<div>
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
<div id="transaction-selection-bar" data-bulk-select-target="selectionBar" class="flex justify-center hidden">
<%= render "selection_bar" %>
</div>

View File

@@ -0,0 +1,2 @@
<%= async_combobox_options @securities,
render_in: { partial: "account/trades/security" } %>

View File

@@ -1,9 +1,8 @@
<%# locals: (entry:, selectable: true, editable: true, short: false, show_tags: false, **opts) %>
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
<% transaction, account = entry.account_transaction, entry.account %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<% name_col_span = unconfirmed_transfer?(entry) ? "col-span-10" : short ? "col-span-6" : "col-span-4" %>
<div class="pr-10 flex items-center gap-4 <%= name_col_span %>">
<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="pr-10 flex items-center gap-4 col-span-6">
<% if selectable %>
<%= check_box_tag dom_id(entry, "selection"),
class: "maybe-checkbox maybe-checkbox--light",
@@ -13,15 +12,15 @@
<div class="max-w-full">
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
<%= entry_name(entry).first.upcase %>
<%= transaction.name.first.upcase %>
</div>
<div class="truncate text-gray-900">
<div class="truncate">
<% if entry.new_record? %>
<%= content_tag :p, entry.name %>
<%= content_tag :p, transaction.name %>
<% else %>
<%= link_to entry_name(entry),
account_entry_path(account, entry),
<%= link_to transaction.name,
entry.transfer.present? ? account_transfer_path(entry.transfer, origin:) : account_entry_path(account, entry, origin:),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
<% end %>
@@ -30,46 +29,25 @@
</div>
<% if unconfirmed_transfer?(entry) %>
<% if editable %>
<%= form_with url: unmark_transfers_transactions_path, class: "flex items-center", data: {
turbo_confirm: {
title: t(".remove_transfer"),
body: t(".remove_transfer_body"),
accept: t(".remove_transfer_confirm"),
},
turbo_frame: "_top"
} do |f| %>
<%= f.hidden_field "bulk_update[entry_ids][]", value: entry.id %>
<%= f.button class: "flex items-center justify-center group", title: "Remove transfer" do %>
<%= lucide_icon "arrow-left-right", class: "group-hover:hidden text-gray-500 w-4 h-4" %>
<%= lucide_icon "unlink", class: "hidden group-hover:inline-block text-gray-900 w-4 h-4" %>
<% end %>
<% end %>
<% else %>
<%= lucide_icon "arrow-left-right", class: "text-gray-500 w-4 h-4" %>
<% end %>
<%= render "account/transfers/transfer_toggle", entry: entry %>
<% end %>
</div>
<% unless entry.marked_as_transfer? %>
<% unless short %>
<div class="flex items-center gap-1 <%= show_tags ? "col-span-6" : "col-span-3" %>">
<% if editable %>
<%= render "categories/menu", transaction: transaction %>
<% else %>
<%= render "categories/badge", category: transaction.category %>
<% end %>
<% if show_tags %>
<% transaction.tags.each do |tag| %>
<%= render partial: "tags/badge", locals: { tag: tag } %>
<% end %>
<% end %>
</div>
<% if entry.transfer.present? %>
<% unless show_balance %>
<div class="col-span-2"></div>
<% end %>
<% unless show_tags %>
<%= tag.div class: short ? "col-span-4" : "col-span-3" do %>
<div class="col-span-2">
<%= render "account/transfers/account_logos", transfer: entry.transfer, outflow: entry.outflow? %>
</div>
<% else %>
<div class="flex items-center gap-1 col-span-2">
<%= render "categories/menu", transaction: transaction, origin: origin %>
</div>
<% unless show_balance %>
<%= tag.div class: "col-span-2 overflow-hidden truncate" do %>
<% if entry.new_record? %>
<%= tag.p account.name %>
<% else %>
@@ -87,4 +65,10 @@
format_money(-entry.amount_money),
class: ["text-green-600": entry.inflow?] %>
</div>
<% if show_balance %>
<div class="col-span-2 justify-self-end">
<%= tag.p format_money(entry.trend.current), class: "font-medium text-sm text-gray-900" %>
</div>
<% end %>
</div>

View File

@@ -11,7 +11,7 @@
</div>
<div id="transactions" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".transaction") %>">
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
<div id="transaction-selection-bar" data-bulk-select-target="selectionBar" class="flex justify-center hidden">
<%= render "selection_bar" %>
</div>

View File

@@ -1,5 +1,7 @@
<% entry, transaction, account = @entry, @entry.account_transaction, @entry.account %>
<% origin = params[:origin] %>
<%= drawer do %>
<header class="mb-4 space-y-1">
<div class="flex items-center gap-4">
@@ -31,6 +33,7 @@
url: account_transaction_path(account, entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field :origin, value: origin %>
<%= f.text_field :name,
label: t(".name_label"),
"data-auto-submit-form-target": "auto" %>
@@ -73,6 +76,7 @@
url: account_transaction_path(account, entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field :origin, value: origin %>
<%= f.fields_for :entryable do |ef| %>
<% unless entry.marked_as_transfer? %>
<%= ef.collection_select :category_id,
@@ -110,6 +114,7 @@
url: account_transaction_path(account, entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field :origin, value: origin %>
<%= f.text_area :notes,
label: t(".note_label"),
placeholder: t(".note_placeholder"),
@@ -128,6 +133,7 @@
url: account_transaction_path(account, entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field :origin, value: origin %>
<div class="flex cursor-pointer items-center gap-2 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
@@ -138,28 +144,26 @@
<%= f.check_box :excluded,
class: "sr-only peer",
"data-auto-submit-form-target": "auto" %>
<label for="account_entry_entryable_attributes_excluded"
<label for="account_entry_excluded"
class="maybe-switch"></label>
</div>
</div>
<% end %>
<!-- Delete Transaction Form -->
<% unless entry.marked_as_transfer? %>
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".delete_title") %></h4>
<p class="text-gray-500"><%= t(".delete_subtitle") %></p>
</div>
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".delete_title") %></h4>
<p class="text-gray-500"><%= t(".delete_subtitle") %></p>
</div>
<%= button_to t(".delete"),
<%= button_to t(".delete"),
account_entry_path(account, entry),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm
font-medium border border-alpha-black-200",
data: { turbo_confirm: true, turbo_frame: "_top" } %>
</div>
<% end %>
</div>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,25 @@
<%# locals: (transfer:, outflow: false) %>
<div class="flex items-center gap-2">
<% if outflow %>
<%= link_to transfer.from_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
<%= circle_logo(transfer.from_name[0].upcase, size: "sm") %>
<% end %>
<%= lucide_icon "arrow-right", class: "text-gray-500 w-4 h-4" %>
<%= link_to transfer.to_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
<%= circle_logo(transfer.to_name[0].upcase, size: "sm") %>
<% end %>
<% else %>
<%= link_to transfer.to_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
<%= circle_logo(transfer.to_name[0].upcase, size: "sm") %>
<% end %>
<%= lucide_icon "arrow-left", class: "text-gray-500 w-4 h-4" %>
<%= link_to transfer.from_account, data: { turbo_frame: :_top }, class: "hover:opacity-90" do %>
<%= circle_logo(transfer.from_name[0].upcase, size: "sm") %>
<% end %>
<% end %>
</div>

View File

@@ -26,7 +26,6 @@
</section>
<section class="space-y-2">
<%= f.text_field :name, value: transfer.name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
<%= f.collection_select :from_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %>
<%= f.collection_select :to_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %>
<%= f.money_field :amount, label: t(".amount"), required: true, hide_currency: true %>

View File

@@ -1,49 +0,0 @@
<%# locals: (transfer:, selectable: true, editable: true, short: false, **opts) %>
<%= turbo_frame_tag dom_id(transfer) do %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="col-span-7 flex items-center">
<% if selectable %>
<%= check_box_tag dom_id(transfer, "selection"),
disabled: true,
class: "mr-3 cursor-not-allowed maybe-checkbox maybe-checkbox--light" %>
<% end %>
<%= tag.div class: short ? "max-w-[250px]" : "max-w-[325px]" do %>
<div class="flex items-center gap-2 <%= selectable ? "" : "pl-8" %>">
<%= circle_logo(transfer.from_name[0].upcase) %>
<%= tag.p transfer.name, class: "truncate text-gray-900" %>
</div>
<% end %>
<%= button_to account_transfer_path(transfer),
method: :delete,
class: "ml-2 flex items-center group/transfer hover:bg-gray-50 rounded-md p-1",
data: {
turbo_frame: "_top",
turbo_confirm: {
title: t(".remove_title"),
body: t(".remove_body"),
confirm: t(".remove_confirm")
}
} do %>
<%= lucide_icon "link-2", class: "group-hover/transfer:hidden w-4 h-4 text-gray-500" %>
<%= lucide_icon "unlink", class: "group-hover/transfer:inline-block hidden w-4 h-4 text-gray-500" %>
<% end %>
</div>
<% unless short %>
<div class="col-span-3 flex items-center gap-2">
<%= circle_logo(transfer.from_name[0].upcase, size: "sm") %>
<span class="text-gray-500 font-medium">&rarr;</span>
<%= circle_logo(transfer.to_name[0].upcase, size: "sm") %>
</div>
<% end %>
<div class="ml-auto <%= short ? "col-span-5" : "col-span-2" %>">
<%= tag.p format_money(transfer.amount_money), class: "font-medium" %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,16 @@
<%# locals: (entry:) %>
<%= form_with url: unmark_transfers_transactions_path, class: "flex items-center", data: {
turbo_confirm: {
title: t(".remove_transfer"),
body: t(".remove_transfer_body"),
accept: t(".remove_transfer_confirm"),
},
turbo_frame: "_top"
} do |f| %>
<%= f.hidden_field "bulk_update[entry_ids][]", value: entry.id %>
<%= f.button class: "flex items-center justify-center group", title: "Remove transfer" do %>
<%= lucide_icon "arrow-left-right", class: "group-hover:hidden text-gray-500 w-4 h-4" %>
<%= lucide_icon "unlink", class: "hidden group-hover:inline-block text-gray-900 w-4 h-4" %>
<% end %>
<% end %>

View File

@@ -0,0 +1,121 @@
<%= drawer do %>
<header class="mb-4 space-y-1">
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl">
<%= format_money @transfer.amount_money %>
</span>
<span class="text-lg text-gray-500">
<%= @transfer.amount_money.currency.iso_code %>
</span>
</h3>
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
</div>
<span class="text-sm text-gray-500">
<%= @transfer.name %>
</span>
</header>
<div class="space-y-2">
<!-- Overview Section -->
<%= disclosure t(".overview") do %>
<div class="pb-4 px-3 pt-2 text-sm space-y-3 text-gray-900">
<div class="space-y-3">
<dl class="flex items-center gap-2 justify-between">
<dt class="text-gray-500">To</dt>
<dd class="flex items-center gap-2 font-medium">
<%= render "accounts/logo", account: @transfer.inflow_transaction.account, size: "sm" %>
<%= @transfer.to_name %>
</dd>
</dl>
<dl class="flex items-center gap-2 justify-between">
<dt class="text-gray-500">Date</dt>
<dd class="font-medium"><%= l(@transfer.date, format: :long) %></dd>
</dl>
<dl class="flex items-center gap-2 justify-between">
<dt class="text-gray-500">Amount</dt>
<dd class="font-medium text-red-500"><%= format_money -@transfer.amount_money %></dd>
</dl>
</div>
<div class="bg-alpha-black-100 h-px my-2"></div>
<div class="space-y-3">
<dl class="flex items-center gap-2 justify-between">
<dt class="text-gray-500">From</dt>
<dd class="flex items-center gap-2 font-medium">
<%= render "accounts/logo", account: @transfer.outflow_transaction.account, size: "sm" %>
<%= @transfer.from_name %>
</dd>
</dl>
<dl class="flex items-center gap-2 justify-between">
<dt class="text-gray-500">Date</dt>
<dd class="font-medium"><%= l(@transfer.date, format: :long) %></dd>
</dl>
<dl class="flex items-center gap-2 justify-between">
<dt class="text-gray-500">Amount</dt>
<dd class="font-medium text-green-500">+<%= format_money @transfer.amount_money %></dd>
</dl>
</div>
</div>
<% end %>
<!-- Details Section -->
<%= disclosure t(".details") do %>
<%= styled_form_with model: @transfer,
data: { controller: "auto-submit-form" } do |f| %>
<%= f.text_area :notes,
label: t(".note_label"),
placeholder: t(".note_placeholder"),
value: @transfer.outflow_transaction.notes,
rows: 5,
"data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
<!-- Settings Section -->
<%= disclosure t(".settings") do %>
<div class="pb-4">
<%= styled_form_with model: @transfer,
class: "p-3", data: { controller: "auto-submit-form" } do |f| %>
<div class="flex cursor-pointer items-center gap-2 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
</div>
<div class="relative inline-block select-none">
<%= f.check_box :excluded,
checked: @transfer.inflow_transaction.excluded,
class: "sr-only peer",
"data-auto-submit-form-target": "auto" %>
<label for="account_transfer_excluded"
class="maybe-switch"></label>
</div>
</div>
<% end %>
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".delete_title") %></h4>
<p class="text-gray-500"><%= t(".delete_subtitle") %></p>
</div>
<%= button_to t(".delete"),
account_transfer_path(@transfer),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm
font-medium border border-alpha-black-200",
data: { turbo_confirm: true, turbo_frame: "_top" } %>
</div>
</div>
<% end %>
</div>
<% end %>

View File

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

View File

@@ -1,50 +1,39 @@
<%# locals: (entry:, **opts) %>
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
<% account = entry.account %>
<% valuation = entry.account_valuation %>
<%= turbo_frame_tag dom_id(entry) do %>
<% is_oldest = entry.first_of_type? %>
<div class="p-4 grid grid-cols-12 items-center text-gray-900 text-sm font-medium">
<div class="col-span-8 flex items-center gap-4">
<% if selectable %>
<%= check_box_tag dom_id(entry, "selection"),
class: "maybe-checkbox maybe-checkbox--light",
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
<% end %>
<div class="p-4 grid grid-cols-10 items-center">
<div class="col-span-5 flex items-center gap-4">
<%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: entry_style(entry, is_oldest:).html_safe do %>
<%= lucide_icon entry_icon(entry, is_oldest:), class: "w-4 h-4" %>
<div class="flex items-center gap-3">
<%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(valuation.color) do %>
<%= lucide_icon valuation.icon, class: "w-4 h-4" %>
<% end %>
<div class="text-sm">
<%= tag.p entry.date, class: "text-gray-900 font-medium" %>
<%= tag.p is_oldest ? t(".start_balance") : t(".value_update"), class: "text-gray-500" %>
<div class="truncate text-gray-900">
<% if entry.new_record? %>
<%= content_tag :p, entry.name %>
<% else %>
<%= link_to valuation.name,
account_entry_path(account, entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
<% end %>
</div>
</div>
<div class="col-span-2 justify-self-end">
<%= tag.p format_money(entry.amount_money), class: "font-medium text-sm text-gray-900" %>
</div>
<div class="col-span-2 justify-self-end font-medium text-sm" style="color: <%= entry.trend.color %>">
<% if entry.trend.direction.flat? %>
<%= tag.span t(".no_change"), class: "text-gray-500" %>
<% else %>
<%= tag.span format_money(entry.trend.value) %>
<%= tag.span "(#{entry.trend.percent}%)" %>
<% end %>
</div>
<div class="col-span-1 justify-self-end">
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= contextual_menu_modal_action_item t(".edit_entry"), edit_account_entry_path(account, entry), turbo_frame: dom_id(entry) %>
<%= contextual_menu_destructive_item t(".delete_entry"),
account_entry_path(account, entry),
turbo_frame: "_top",
turbo_confirm: {
title: t(".confirm_title"),
body: t(".confirm_body_html"),
accept: t(".confirm_accept")
} %>
</div>
<% end %>
</div>
</div>
<% end %>
<div class="col-span-2 justify-self-end font-medium text-sm" style="color: <%= valuation.color %>">
<%= tag.span format_money(entry.trend.value) %>
</div>
<div class="col-span-2 justify-self-end">
<%= tag.p format_money(entry.amount_money), class: "font-medium text-sm text-gray-900" %>
</div>
</div>

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