Compare commits

...

49 Commits

Author SHA1 Message Date
Zach Gollwitzer
b75b41a5e2 Bump to v0.2.0-alpha.1
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-10-25 09:37:50 -04:00
bruno costanzo
2cc89195bf Feature | Filter on uncategorized transactions (#1359)
* allow filtering uncategorized transactions

* user can filter uncategorized transactions test

* rubocop linting
2024-10-25 09:37:05 -04:00
Josh Pigford
aa3342b0dc Stock imports (#1363)
* Initial pass

* Marketstack data provider

* Marketstack data provider

* Refactor a bit
2024-10-24 16:36:50 -05:00
Zach Gollwitzer
b611dfdf37 Add back good job dashboard with auth (#1364) 2024-10-24 17:28:29 -04:00
Zach Gollwitzer
ba49fea89a Locale updates 2024-10-24 16:43:59 -04:00
Zach Gollwitzer
89e107e36c Remove good job dashboard route 2024-10-24 15:56:58 -04:00
Zach Gollwitzer
d93fbbcaa8 Fix tooltip on charts 2024-10-24 12:06:42 -04:00
Josh Pigford
e6403fab70 Modal design tweaks 2024-10-24 10:16:24 -05:00
Zach Gollwitzer
6baffe7539 Beta Testing Round 3 Bug Fixes (#1357)
* Clean up env example files

* Fix duplicate category creations

* Fix duplicate tag and merchant creation

* Add initial valuation to imported accounts

* Add upgrade modal prompt

* Don't hide content on billing page

* Add temporary session for new customers

* Lint fixes

* Fix unused translations

* Fix system tests
2024-10-24 11:02:27 -04:00
Zach Gollwitzer
1d20de770f User Onboarding + Bug Fixes (#1352)
* Bump min supported date to 20 years

* Add basic onboarding

* User onboarding

* Complete onboarding flow

* Cleanup, add user profile update test
2024-10-23 11:20:55 -04:00
Josh Pigford
73e184ad3d Stock Exchanges with seed (#1351)
* Stock Exchanges with seed

* Run the seed file on migration

* Fix for enum column
2024-10-22 14:30:57 -05:00
dependabot[bot]
d3a6f7e0f0 Bump tailwindcss-rails from 2.7.9 to 3.0.0 (#1341)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.7.9 to 3.0.0.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.7.9...v3.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-22 15:11:26 -04:00
Josh Pigford
9313620968 Updated Synth env variable description 2024-10-22 13:10:51 -05:00
Josh Pigford
a4e87ffb4d Delete extensions.json 2024-10-21 20:20:52 -05:00
Zach Gollwitzer
728b10d08e Fix trade import mapping bug 2024-10-21 12:26:39 -04:00
dependabot[bot]
a27b17deae Bump ruby-lsp-rails from 0.3.19 to 0.3.20 (#1339)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.19 to 0.3.20.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.19...v0.3.20)

---
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-21 10:14:05 -04:00
Nico
1b654faf9a Fixes issue with mapping values during the transactions import (#1327)
* Adds custom debounce timeout to autosubmit form controller

- There's a default debounce timeout based on element type
- You can parameterize debounce timeout on a data-attribute

* Adds corrections based on js_lint

* Restores sleep on test

---------

Co-authored-by: Nicolás Galdámez <nicolas.galdamez@unagisoftware.com>
2024-10-21 10:13:55 -04:00
dependabot[bot]
9b6a2cce56 Bump turbo-rails from 2.0.10 to 2.0.11 (#1343)
Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.10 to 2.0.11.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.10...v2.0.11)

---
updated-dependencies:
- dependency-name: turbo-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-21 10:12:35 -04:00
dependabot[bot]
5ff9012d3e Bump rails from 7.2.1 to 7.2.1.1 (#1340)
Bumps [rails](https://github.com/rails/rails) from 7.2.1 to 7.2.1.1.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](https://github.com/rails/rails/compare/v7.2.1...v7.2.1.1)

---
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-21 10:10:15 -04:00
dependabot[bot]
da7f19d5ab Bump erb_lint from 0.6.0 to 0.7.0 (#1337)
Bumps [erb_lint](https://github.com/Shopify/erb-lint) from 0.6.0 to 0.7.0.
- [Release notes](https://github.com/Shopify/erb-lint/releases)
- [Commits](https://github.com/Shopify/erb-lint/compare/v0.6.0...v0.7.0)

---
updated-dependencies:
- dependency-name: erb_lint
  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-21 09:53:51 -04:00
dependabot[bot]
a2e8fb5ce1 Bump good_job from 4.4.1 to 4.4.2 (#1336)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.4.1 to 4.4.2.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.4.1...v4.4.2)

---
updated-dependencies:
- dependency-name: good_job
  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-21 09:53:41 -04:00
dependabot[bot]
b074762809 Bump faker from 3.4.2 to 3.5.1 (#1338)
Bumps [faker](https://github.com/faker-ruby/faker) from 3.4.2 to 3.5.1.
- [Release notes](https://github.com/faker-ruby/faker/releases)
- [Changelog](https://github.com/faker-ruby/faker/blob/main/CHANGELOG.md)
- [Commits](https://github.com/faker-ruby/faker/compare/v3.4.2...v3.5.1)

---
updated-dependencies:
- dependency-name: faker
  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-21 09:53:33 -04:00
dependabot[bot]
3cc4cba2b3 Bump octokit from 9.1.0 to 9.2.0 (#1342)
Bumps [octokit](https://github.com/octokit/octokit.rb) from 9.1.0 to 9.2.0.
- [Release notes](https://github.com/octokit/octokit.rb/releases)
- [Changelog](https://github.com/octokit/octokit.rb/blob/main/RELEASE.md)
- [Commits](https://github.com/octokit/octokit.rb/compare/v9.1.0...v9.2.0)

---
updated-dependencies:
- dependency-name: octokit
  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>
2024-10-21 09:52:49 -04:00
dependabot[bot]
cb752370cb Bump aws-sdk-s3 from 1.167.0 to 1.169.0 (#1344)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.167.0 to 1.169.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  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>
2024-10-21 09:52:37 -04:00
dependabot[bot]
720d7aedaf Bump stripe from 13.0.0 to 13.0.1 (#1345)
Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.0.0 to 13.0.1.
- [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.0...v13.0.1)

---
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-21 09:52:27 -04:00
Josh Pigford
07264e86cb Add accounts count to Intercom 2024-10-19 14:54:51 -05:00
Zach Gollwitzer
3c0fdd84ee Fix mode bug 2024-10-18 18:25:17 -04:00
Zach Gollwitzer
263d65ea7e Basic account onboarding (#1328)
* Basic account onboarding

* Cleanup
2024-10-18 17:18:54 -04:00
Zach Gollwitzer
e8e100e1d8 Rework account views and addition flow (#1324)
* Move accountable partials

* Split accountables into separate view partials

* Fix test

* Add form to permitted partials

* Fix failing system tests

* Update new account modal views

* New sync algorithm implementation

* Update account system test assertions to match new behavior

* Fix off by 1 date error

* Revert new balance sync algorithm

* Add missing account overviews
2024-10-18 14:37:42 -04:00
Josh Pigford
c7c281073f Impersonation (#1325)
* Initial impersonation

* Impersonation audit

* Keep super admin separate

* Remove vscode settings

* Comment cleanup

* Comment out impersonation fixtures for now

* Remove unused controlelr

* Add impersonation testing (#1326)

* Add impersonation testing

* Remove unused method

* Update schema.rb

* Update brakeman

---------

Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-10-18 11:26:58 -05:00
Ender Ahmet Yurt
4a3685f503 Redirect upload step (#1323)
* Redirect upload step

* Change redirect page regarding state of the import
2024-10-18 08:10:18 -05:00
Josh Pigford
75a390f03e Account indexes to address some performance issues 2024-10-17 15:45:13 -05:00
Josh Pigford
d4bfcfb6f4 Fix for transaction drawer securities missing prices
Fixes #1321
2024-10-17 10:52:04 -05:00
Josh Pigford
b98f35af0e Another tweak to the bug template 2024-10-17 10:39:56 -05:00
Josh Pigford
629565f7d8 Updated bug report template 2024-10-17 10:20:42 -05:00
Josh Pigford
4118cc8a31 Fix for scrollbars on alerts
Fixes #1320
2024-10-17 10:16:34 -05:00
Josh Pigford
61bf53f233 Rescue RecordNotUnique
Fixes #1319
2024-10-17 09:52:06 -05:00
Guillem Arias Fauste
7f4c1755ef add dashboard account pill tooltips (#1315)
* add dashboard account pill tooltips

* Update app/views/shared/_text_tooltip.erb

Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>

---------

Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>
Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
2024-10-16 13:14:43 -04:00
Tony Vincent
76decc06c3 Maintain order (#1318) 2024-10-16 12:09:52 -04:00
Zach Gollwitzer
f3bb80dde6 Fix pie chart 2024-10-14 17:21:51 -04:00
oxdev03
4ad28d6eff Add BiomeJS for Linting and Formatting JavaScript relates to #1295 (#1299)
* chore: add formatting and linting for javascript code relates to #1295

* use spaces instaed

* add to recommended extensions

* only enforce lint

* auto save
2024-10-14 17:09:27 -04:00
dependabot[bot]
fa3b8b078c Bump good_job from 4.3.0 to 4.4.1 (#1302)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.3.0 to 4.4.1.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.3.0...v4.4.1)

---
updated-dependencies:
- dependency-name: good_job
  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>
2024-10-14 11:44:13 -04:00
dependabot[bot]
d4e7a983f4 Bump tailwindcss-rails from 2.7.7 to 2.7.9 (#1304)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.7.7 to 2.7.9.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/v2.7.9/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.7.7...v2.7.9)

---
updated-dependencies:
- dependency-name: tailwindcss-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-14 11:02:10 -04:00
dependabot[bot]
7f7140b1cc Bump ruby-lsp-rails from 0.3.18 to 0.3.19 (#1300)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.18 to 0.3.19.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.18...v0.3.19)

---
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-14 11:01:56 -04:00
dependabot[bot]
437aa4bd39 Bump importmap-rails from 2.0.2 to 2.0.3 (#1301)
Bumps [importmap-rails](https://github.com/rails/importmap-rails) from 2.0.2 to 2.0.3.
- [Release notes](https://github.com/rails/importmap-rails/releases)
- [Commits](https://github.com/rails/importmap-rails/compare/v2.0.2...v2.0.3)

---
updated-dependencies:
- dependency-name: importmap-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-14 11:01:44 -04:00
dependabot[bot]
eabec71f70 Bump rails-settings-cached from 2.9.4 to 2.9.5 (#1305)
Bumps [rails-settings-cached](https://github.com/huacnlee/rails-settings-cached) from 2.9.4 to 2.9.5.
- [Release notes](https://github.com/huacnlee/rails-settings-cached/releases)
- [Changelog](https://github.com/huacnlee/rails-settings-cached/blob/main/CHANGELOG.md)
- [Commits](https://github.com/huacnlee/rails-settings-cached/compare/v2.9.4...v2.9.5)

---
updated-dependencies:
- dependency-name: rails-settings-cached
  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-14 11:01:35 -04:00
dependabot[bot]
3bc960e6c1 Bump sentry-ruby from 5.20.1 to 5.21.0 (#1306)
Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 5.20.1 to 5.21.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/5.20.1...5.21.0)

---
updated-dependencies:
- dependency-name: sentry-ruby
  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>
2024-10-14 11:01:17 -04:00
Alex Hatzenbuhler
57a81e44ef Add period to value delete modal (#1297) 2024-10-14 10:19:33 -04:00
Zach Gollwitzer
e357c0485f Temp fix for Stimulus charts 2024-10-11 14:40:13 -04:00
253 changed files with 7768 additions and 1403 deletions

View File

@@ -17,4 +17,8 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
RUN gem install bundler
RUN gem install foreman
# Install Node.js 20
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs
WORKDIR /workspace

View File

@@ -10,5 +10,13 @@
"remoteEnv": {
"PATH": "/workspace/bin:${containerEnv:PATH}"
},
"postCreateCommand": "bundle install"
"postCreateCommand": "bundle install && npm install",
"customizations": {
"vscode": {
"extensions": [
"biomejs.biome",
"EditorConfig.EditorConfig"
]
}
}
}

View File

@@ -1,11 +1,24 @@
# ================================ PLEASE READ ==========================================
# This file outlines all the possible environment variables supported by the Maybe app.
#
# This includes several features that are for our "hosted" version of Maybe, which most
# open-source contributors won't need.
#
# If you are developing locally, you should be referencing `.env.local.example` instead.
# =======================================================================================
# Custom port config
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
PORT=3000
# Exchange Rate API
# This is used to convert between different currencies in the app. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
# 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.
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.

8
.env.local.example Normal file
View File

@@ -0,0 +1,8 @@
# To enable / disable self-hosting features.
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

8
.env.test Normal file
View File

@@ -0,0 +1,8 @@
SELF_HOSTED=false
SYNTH_API_KEY=fookey
# Set to true if you want SimpleCov reports generated
COVERAGE=false
# Set to true to run test suite serially
DISABLE_PARALLELIZATION=false

View File

@@ -20,6 +20,12 @@ Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**What version of Maybe are you using?**
This could be "Hosted" (i.e. app.maybe.co) or "Self-hosted". If "Self-hosted", please include the version you're currently on.
**What operating system and browser are you using?**
The more info the better.
**Screenshots / Recordings**
If applicable, add screenshots or short video recordings to help show the bug in more detail.

View File

@@ -52,6 +52,26 @@ jobs:
- name: Lint code for consistent style
run: bin/rubocop -f github
lint_js:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install
shell: bash
- name: Lint/Format js code
run: npm run lint
test:
runs-on: ubuntu-latest
timeout-minutes: 10

13
.gitignore vendored
View File

@@ -10,8 +10,8 @@
# Ignore all environment files (except templates).
/.env*
!/.env*.erb
!.env.example
!.env.test.example
!.env.test
!.env*.example
# Ignore all logfiles and tempfiles.
/log/*
@@ -43,7 +43,9 @@
.idea
# Ignore VS Code
.vscode
.vscode/*
!.vscode/extensions.json
!.vscode/*.code-snippets
# Ignore macOS specific files
*/.DS_Store
@@ -59,4 +61,7 @@ compose-dev.yaml
gcp-storage-keyfile.json
coverage
.cursorrules
.cursorrules
# Ignore node related files
node_modules

View File

@@ -8,29 +8,29 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
actioncable (7.2.1.1)
actionpack (= 7.2.1.1)
activesupport (= 7.2.1.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
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)
mail (>= 2.8.0)
actionmailer (7.2.1)
actionpack (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activesupport (= 7.2.1)
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)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.1)
actionview (= 7.2.1)
activesupport (= 7.2.1)
actionpack (7.2.1.1)
actionview (= 7.2.1.1)
activesupport (= 7.2.1.1)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.2)
@@ -39,35 +39,35 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.1)
actionpack (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
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)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.1)
activesupport (= 7.2.1)
actionview (7.2.1.1)
activesupport (= 7.2.1.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.1)
activesupport (= 7.2.1)
activejob (7.2.1.1)
activesupport (= 7.2.1.1)
globalid (>= 0.3.6)
activemodel (7.2.1)
activesupport (= 7.2.1)
activerecord (7.2.1)
activemodel (= 7.2.1)
activesupport (= 7.2.1)
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)
timeout (>= 0.4.0)
activestorage (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activesupport (= 7.2.1)
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)
marcel (~> 1.0)
activesupport (7.2.1)
activesupport (7.2.1.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -82,17 +82,17 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.985.0)
aws-sdk-core (3.209.1)
aws-partitions (1.992.0)
aws-sdk-core (3.210.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.94.0)
aws-sdk-core (~> 3, >= 3.207.0)
aws-sdk-kms (1.95.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.167.0)
aws-sdk-core (~> 3, >= 3.207.0)
aws-sdk-s3 (1.169.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.0)
@@ -110,7 +110,7 @@ GEM
bindex (0.8.1)
bootsnap (1.18.4)
msgpack (~> 1.2)
brakeman (6.2.1)
brakeman (6.2.2)
racc
builder (3.3.0)
capybara (3.40.0)
@@ -141,7 +141,7 @@ GEM
dotenv (= 3.1.4)
railties (>= 6.1)
drb (2.2.1)
erb_lint (0.6.0)
erb_lint (0.7.0)
activesupport
better_html (>= 2.0.1)
parser (>= 2.7.1.4)
@@ -151,7 +151,7 @@ GEM
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
faker (3.4.2)
faker (3.5.1)
i18n (>= 1.8.11, < 2)
faraday (2.12.0)
faraday-net_http (>= 2.0, < 3.4)
@@ -174,7 +174,7 @@ GEM
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (4.3.0)
good_job (4.4.2)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
@@ -203,7 +203,7 @@ GEM
image_processing (1.13.0)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.0.2)
importmap-rails (2.0.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
@@ -247,7 +247,7 @@ GEM
multipart-post (2.4.1)
net-http (0.4.1)
uri
net-imap (0.4.14)
net-imap (0.5.0)
date
net-protocol
net-pop (0.1.2)
@@ -269,16 +269,16 @@ GEM
racc (~> 1.4)
nokogiri (1.16.7-x86_64-linux)
racc (~> 1.4)
octokit (9.1.0)
octokit (9.2.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (9.1.0)
parallel (1.25.1)
parser (3.3.4.0)
parallel (1.26.3)
parser (3.3.5.0)
ast (~> 2.4.1)
racc
pg (1.5.8)
prism (1.1.0)
prism (1.2.0)
propshaft (1.1.0)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
@@ -291,7 +291,7 @@ GEM
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.7)
rack (3.1.8)
rack-session (2.0.0)
rack (>= 3.0.0)
rack-test (2.1.0)
@@ -299,20 +299,20 @@ GEM
rackup (2.1.0)
rack (>= 3)
webrick (~> 1.8)
rails (7.2.1)
actioncable (= 7.2.1)
actionmailbox (= 7.2.1)
actionmailer (= 7.2.1)
actionpack (= 7.2.1)
actiontext (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activemodel (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
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)
bundler (>= 1.15.0)
railties (= 7.2.1)
railties (= 7.2.1.1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -323,12 +323,12 @@ GEM
rails-i18n (7.0.9)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
rails-settings-cached (2.9.4)
rails-settings-cached (2.9.5)
activerecord (>= 5.0.0)
railties (>= 5.0.0)
railties (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
railties (7.2.1.1)
actionpack (= 7.2.1.1)
activesupport (= 7.2.1.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -348,18 +348,17 @@ GEM
reline (0.5.10)
io-console (~> 0.5)
rexml (3.3.8)
rubocop (1.65.1)
rubocop (1.67.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.4, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-ast (>= 1.32.2, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.31.3)
rubocop-ast (1.32.3)
parser (>= 3.3.1.0)
rubocop-minitest (0.35.0)
rubocop (>= 1.61, < 2.0)
@@ -377,13 +376,13 @@ GEM
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.19.1)
ruby-lsp (0.20.1)
language_server-protocol (~> 3.17.0)
prism (>= 1.1, < 2.0)
prism (>= 1.2, < 2.0)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.3.18)
ruby-lsp (>= 0.19.0, < 0.20.0)
ruby-lsp-rails (0.3.20)
ruby-lsp (>= 0.20.0, < 0.21.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.2)
ffi (~> 1.12)
@@ -400,10 +399,10 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sentry-rails (5.20.1)
sentry-rails (5.21.0)
railties (>= 5.0)
sentry-ruby (~> 5.20.1)
sentry-ruby (5.20.1)
sentry-ruby (~> 5.21.0)
sentry-ruby (5.21.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
simplecov (0.22.0)
@@ -413,34 +412,31 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11597)
sorbet-runtime (0.5.11609)
stackprof (0.2.26)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.1)
stripe (13.0.0)
tailwindcss-rails (2.7.7)
railties (>= 7.0.0)
tailwindcss-rails (2.7.7-aarch64-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.7.7-arm-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.7.7-arm64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.7.7-x86_64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.7.7-x86_64-linux)
stripe (13.0.1)
tailwindcss-rails (3.0.0)
railties (>= 7.0.0)
tailwindcss-ruby
tailwindcss-ruby (3.4.14)
tailwindcss-ruby (3.4.14-aarch64-linux)
tailwindcss-ruby (3.4.14-arm-linux)
tailwindcss-ruby (3.4.14-arm64-darwin)
tailwindcss-ruby (3.4.14-x86_64-darwin)
tailwindcss-ruby (3.4.14-x86_64-linux)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
thor (1.3.2)
timeout (0.4.1)
turbo-rails (2.0.10)
turbo-rails (2.0.11)
actionpack (>= 6.0.0)
railties (>= 6.0.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0)
unicode-display_width (2.6.0)
uri (0.13.1)
useragent (0.16.10)
vcr (6.3.1)
@@ -461,7 +457,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.18)
zeitwerk (2.7.1)
PLATFORMS
aarch64-linux

View File

@@ -49,7 +49,7 @@ After cloning the repo, the basic setup commands are:
```sh
cd maybe
cp .env.example .env
cp .env.local.example .env.local
bin/setup
bin/dev

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame 1321315963">
<rect width="20" height="20" rx="10" fill="#635BFF"/>
<path id="Vector" fill-rule="evenodd" clip-rule="evenodd" d="M9.35663 7.69056C9.35663 7.20077 9.75747 7.01238 10.4214 7.01238C11.3734 7.01238 12.5759 7.30124 13.5279 7.81615V4.86482C12.4882 4.45037 11.461 4.28711 10.4214 4.28711C7.87854 4.28711 6.1875 5.61835 6.1875 7.84127C6.1875 11.3075 10.9475 10.7549 10.9475 12.2494C10.9475 12.8271 10.4464 13.0155 9.74495 13.0155C8.70527 13.0155 7.37749 12.5885 6.32529 12.0108V14.9998C7.49023 15.5022 8.66769 15.7157 9.74495 15.7157C12.3504 15.7157 14.1416 14.4221 14.1416 12.1741C14.1291 8.43154 9.35663 9.09716 9.35663 7.69056Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 775 B

View File

@@ -1,10 +0,0 @@
class Account::LogosController < ApplicationController
def show
@account = Current.family.accounts.find(params[:account_id])
render_placeholder
end
def render_placeholder
render formats: :svg
end
end

View File

@@ -23,11 +23,8 @@ class AccountsController < ApplicationController
end
def new
@account = Account.new(
accountable: Accountable.from_type(params[:type])&.new,
currency: Current.family.currency
)
@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]
@@ -36,8 +33,6 @@ class AccountsController < ApplicationController
end
def show
@series = @account.series(period: @period)
@trend = @series.trend
end
def edit
@@ -57,6 +52,7 @@ class AccountsController < ApplicationController
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
@@ -83,6 +79,6 @@ class AccountsController < ApplicationController
end
def account_params
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id)
params.require(:account).permit(:name, :accountable_type, :mode, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id)
end
end

View File

@@ -1,8 +1,23 @@
class ApplicationController < ActionController::Base
include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable
include Pagy::Backend
helper_method :require_upgrade?, :subscription_pending?
private
def require_upgrade?
return false if self_hosted?
return false unless Current.session
return false if Current.family.subscribed?
return false if subscription_pending? || request.path == settings_billing_path
true
end
def subscription_pending?
subscribed_at = Current.session.subscribed_at
subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago
end
def with_sidebar
return "turbo_rails/frame" if turbo_frame_request?

View File

@@ -1,7 +1,7 @@
class CategoriesController < ApplicationController
layout :with_sidebar
before_action :set_category, only: %i[edit update]
before_action :set_category, only: %i[edit update destroy]
before_action :set_transaction, only: :create
def index
@@ -13,12 +13,14 @@ class CategoriesController < ApplicationController
end
def create
Category.transaction do
category = Current.family.categories.create!(category_params)
@transaction.update!(category_id: category.id) if @transaction
end
@category = Current.family.categories.new(category_params)
redirect_back_or_to transactions_path, notice: t(".success")
if @category.save
@transaction.update(category_id: @category.id) if @transaction
redirect_back_or_to transactions_path, notice: t(".success")
else
redirect_back_or_to transactions_path, alert: t(".failure", error: @category.errors.full_messages.to_sentence)
end
end
def edit
@@ -30,6 +32,12 @@ class CategoriesController < ApplicationController
redirect_back_or_to transactions_path, notice: t(".success")
end
def destroy
@category.destroy
redirect_back_or_to categories_path, notice: t(".success")
end
private
def set_category
@category = Current.family.categories.find(params[:id])

View File

@@ -14,7 +14,7 @@ module Authentication
private
def authenticate_user!
if session_record = Session.find_by_id(cookies.signed[:session_token])
if session_record = find_session_by_cookie
Current.session = session_record
else
if self_hosted_first_login?
@@ -25,6 +25,10 @@ module Authentication
end
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_token])
end
def create_session_for(user)
session = user.sessions.create!
cookies.signed.permanent[:session_token] = { value: session.id, httponly: true }

View File

@@ -0,0 +1,21 @@
module Impersonatable
extend ActiveSupport::Concern
included do
after_action :create_impersonation_session_log
end
private
def create_impersonation_session_log
return unless Current.session&.active_impersonator_session.present?
Current.session.active_impersonator_session.logs.create!(
controller: controller_name,
action: action_name,
path: request.fullpath,
method: request.method,
ip_address: request.ip,
user_agent: request.user_agent
)
end
end

View File

@@ -0,0 +1,17 @@
module Onboardable
extend ActiveSupport::Concern
included do
before_action :redirect_to_onboarding, if: :needs_onboarding?
end
private
def redirect_to_onboarding
redirect_to onboarding_path
end
def needs_onboarding?
Current.user && Current.user.onboarded_at.blank? &&
!%w[/users /onboarding /sessions].any? { |path| request.path.start_with?(path) }
end
end

View File

@@ -27,7 +27,7 @@ class CreditCardsController < ApplicationController
def account_params
params.require(:account)
.permit(
:name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type,
:name, :balance, :institution_id, :mode, :start_date, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:available_credit,

View File

@@ -0,0 +1,58 @@
class ImpersonationSessionsController < ApplicationController
before_action :require_super_admin!, only: [ :create, :join, :leave ]
before_action :set_impersonation_session, only: [ :approve, :reject, :complete ]
def create
Current.true_user.request_impersonation_for(session_params[:impersonated_id])
redirect_to root_path, notice: t(".success")
end
def join
@impersonation_session = Current.true_user.impersonator_support_sessions.find_by(id: params[:impersonation_session_id])
Current.session.update!(active_impersonator_session: @impersonation_session)
redirect_to root_path, notice: t(".success")
end
def leave
Current.session.update!(active_impersonator_session: nil)
redirect_to root_path, notice: t(".success")
end
def approve
raise_unauthorized! unless @impersonation_session.impersonated == Current.true_user
@impersonation_session.approve!
redirect_to root_path, notice: t(".success")
end
def reject
raise_unauthorized! unless @impersonation_session.impersonated == Current.true_user
@impersonation_session.reject!
redirect_to root_path, notice: t(".success")
end
def complete
@impersonation_session.complete!
redirect_to root_path, notice: t(".success")
end
private
def session_params
params.require(:impersonation_session).permit(:impersonated_id)
end
def set_impersonation_session
@impersonation_session =
Current.true_user.impersonated_support_sessions.find_by(id: params[:id]) ||
Current.true_user.impersonator_support_sessions.find_by(id: params[:id])
end
def require_super_admin!
raise_unauthorized! unless Current.true_user&.super_admin?
end
def raise_unauthorized!
raise ActionController::RoutingError.new("Not Found")
end
end

View File

@@ -20,6 +20,21 @@ class Import::ConfigurationsController < ApplicationController
end
def import_params
params.require(:import).permit(:date_col_label, :date_format, :name_col_label, :category_col_label, :tags_col_label, :amount_col_label, :signage_convention, :account_col_label, :notes_col_label, :entity_type_col_label)
params.require(:import).permit(
:date_col_label,
:amount_col_label,
:name_col_label,
:category_col_label,
:tags_col_label,
:account_col_label,
:qty_col_label,
:ticker_col_label,
:price_col_label,
:entity_type_col_label,
:notes_col_label,
:currency_col_label,
:date_format,
:signage_convention
)
end
end

View File

@@ -24,7 +24,11 @@ class ImportsController < ApplicationController
end
def show
redirect_to import_confirm_path(@import), alert: "Please finalize your mappings before proceeding." unless @import.publishable?
if !@import.uploaded?
redirect_to import_upload_path(@import), alert: "Please finalize your file upload."
elsif !@import.publishable?
redirect_to import_confirm_path(@import), alert: "Please finalize your mappings before proceeding."
end
end
def destroy

View File

@@ -27,7 +27,7 @@ class LoansController < ApplicationController
def account_params
params.require(:account)
.permit(
:name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type,
:name, :balance, :institution_id, :start_date, :mode, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:rate_type,

View File

@@ -12,8 +12,13 @@ class MerchantsController < ApplicationController
end
def create
Current.family.merchants.create!(merchant_params)
redirect_to merchants_path, notice: t(".success")
@merchant = Current.family.merchants.new(merchant_params)
if @merchant.save
redirect_to merchants_path, notice: t(".success")
else
redirect_to merchants_path, alert: t(".error", error: @merchant.errors.full_messages.to_sentence)
end
end
def edit

View File

@@ -0,0 +1,19 @@
class OnboardingsController < ApplicationController
layout "application"
before_action :set_user
def show
end
def profile
end
def preferences
end
private
def set_user
@user = Current.user
end
end

View File

@@ -27,7 +27,7 @@ class PropertiesController < ApplicationController
def account_params
params.require(:account)
.permit(
:name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type,
:name, :balance, :institution_id, :start_date, :mode, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:year_built,

View File

@@ -0,0 +1,5 @@
class SecuritiesController < ApplicationController
def import
SecuritiesImportJob.perform_later(params[:exchange_mic])
end
end

View File

@@ -19,7 +19,7 @@ class SessionsController < ApplicationController
def destroy
@session.destroy
redirect_to root_path, notice: t(".logout_successful")
redirect_to new_session_path, notice: t(".logout_successful")
end
private

View File

@@ -1,2 +1,5 @@
class Settings::BillingsController < SettingsController
def show
@user = Current.user
end
end

View File

@@ -1,26 +1,5 @@
class Settings::PreferencesController < SettingsController
def edit
def show
@user = Current.user
end
def update
preference_params_with_family = preference_params
if Current.family && preference_params[:family_attributes]
family_attributes = preference_params[:family_attributes].merge({ id: Current.family.id })
preference_params_with_family[:family_attributes] = family_attributes
end
if Current.user.update(preference_params_with_family)
redirect_to settings_preferences_path, notice: t(".success")
else
redirect_to settings_preferences_path, notice: t(".success")
render :show, status: :unprocessable_entity
end
end
private
def preference_params
params.require(:user).permit(family_attributes: [ :id, :currency, :locale ])
end
end

View File

@@ -1,38 +1,5 @@
class Settings::ProfilesController < SettingsController
def show
@user = Current.user
end
def update
user_params_with_family = user_params
if params[:user][:delete_profile_image] == "true"
Current.user.profile_image.purge
end
if Current.family && user_params_with_family[:family_attributes]
family_attributes = user_params_with_family[:family_attributes].merge({ id: Current.family.id })
user_params_with_family[:family_attributes] = family_attributes
end
if Current.user.update(user_params_with_family)
redirect_to settings_profile_path, notice: t(".success")
else
redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence
end
end
def destroy
if Current.user.deactivate
Current.session.destroy
redirect_to root_path, notice: t(".success")
else
redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence
end
end
private
def user_params
params.require(:user).permit(:first_name, :last_name, :profile_image,
family_attributes: [ :name, :id ])
end
end

View File

@@ -1,16 +1,14 @@
class SubscriptionsController < ApplicationController
def new
client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
if Current.family.stripe_customer_id.blank?
customer = client.v1.customers.create(
customer = stripe_client.v1.customers.create(
email: Current.family.primary_user.email,
metadata: { family_id: Current.family.id }
)
Current.family.update(stripe_customer_id: customer.id)
end
session = client.v1.checkout.sessions.create({
session = stripe_client.v1.checkout.sessions.create({
customer: Current.family.stripe_customer_id,
line_items: [ {
price: ENV["STRIPE_PLAN_ID"],
@@ -18,7 +16,7 @@ class SubscriptionsController < ApplicationController
} ],
mode: "subscription",
allow_promotion_codes: true,
success_url: settings_billing_url,
success_url: success_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url: settings_billing_url
})
@@ -26,12 +24,24 @@ class SubscriptionsController < ApplicationController
end
def show
client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
portal_session = client.v1.billing_portal.sessions.create(
portal_session = stripe_client.v1.billing_portal.sessions.create(
customer: Current.family.stripe_customer_id,
return_url: settings_billing_url
)
redirect_to portal_session.url, allow_other_host: true, status: :see_other
end
def success
checkout_session = stripe_client.v1.checkout.sessions.retrieve(params[:session_id])
Current.session.update(subscribed_at: Time.at(checkout_session.created))
redirect_to root_path, notice: "You have successfully subscribed to Maybe+."
rescue Stripe::InvalidRequestError
redirect_to settings_billing_path, alert: "Something went wrong processing your subscription. Please contact us to get this fixed."
end
private
def stripe_client
@stripe_client ||= Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
end
end

View File

@@ -1,7 +1,7 @@
class TagsController < ApplicationController
layout :with_sidebar
before_action :set_tag, only: %i[edit update]
before_action :set_tag, only: %i[edit update destroy]
def index
@tags = Current.family.tags.alphabetically
@@ -12,8 +12,13 @@ class TagsController < ApplicationController
end
def create
Current.family.tags.create!(tag_params)
redirect_to tags_path, notice: t(".created")
@tag = Current.family.tags.new(tag_params)
if @tag.save
redirect_to tags_path, notice: t(".created")
else
redirect_to tags_path, alert: t(".error", error: @tag.errors.full_messages.to_sentence)
end
end
def edit
@@ -24,6 +29,11 @@ class TagsController < ApplicationController
redirect_to tags_path, notice: t(".updated")
end
def destroy
@tag.destroy!
redirect_to tags_path, notice: t(".deleted")
end
private
def set_tag

View File

@@ -0,0 +1,51 @@
class UsersController < ApplicationController
before_action :set_user
def update
@user = Current.user
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
@user.profile_image.purge if should_purge_profile_image?
handle_redirect(t(".success"))
end
def destroy
if @user.deactivate
Current.session.destroy
redirect_to root_path, notice: t(".success")
else
redirect_to settings_profile_path, alert: @user.errors.full_messages.to_sentence
end
end
private
def handle_redirect(notice)
case user_params[:redirect_to]
when "onboarding_preferences"
redirect_to preferences_onboarding_path
when "home"
redirect_to root_path
when "preferences"
redirect_to settings_preferences_path, notice: notice
else
redirect_to settings_profile_path, notice: notice
end
end
def should_purge_profile_image?
user_params[:delete_profile_image] == "1" &&
user_params[:profile_image].blank?
end
def user_params
params.require(:user).permit(
:first_name, :last_name, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
family_attributes: [ :name, :currency, :country, :locale, :date_format, :id ]
)
end
def set_user
@user = Current.user
end
end

View File

@@ -27,7 +27,7 @@ class VehiclesController < ApplicationController
def account_params
params.require(:account)
.permit(
:name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type,
:name, :balance, :institution_id, :start_date, :mode, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:make,

View File

@@ -1,4 +1,14 @@
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
raise "Unpermitted accountable partial: #{name}" unless permitted_names.include?(name)
"accounts/accountables/#{folder}/#{name}"
end
def summary_card(title:, &block)
content = capture(&block)
render "accounts/summary_card", title: title, content: content
@@ -86,7 +96,7 @@ module AccountsHelper
def account_groups(period: nil)
assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
[ assets.children, liabilities.children ].flatten
[ assets.children.sort_by(&:name), liabilities.children.sort_by(&:name) ].flatten
end
private

View File

@@ -1,6 +1,19 @@
module ApplicationHelper
include Pagy::Frontend
def date_format_options
[
[ "DD-MM-YYYY", "%d-%m-%Y" ],
[ "MM-DD-YYYY", "%m-%d-%Y" ],
[ "YYYY-MM-DD", "%Y-%m-%d" ],
[ "DD/MM/YYYY", "%d/%m/%Y" ],
[ "YYYY/MM/DD", "%Y/%m/%d" ],
[ "MM/DD/YYYY", "%m/%d/%Y" ],
[ "D/MM/YYYY", "%e/%m/%Y" ],
[ "YYYY.MM.DD", "%Y.%m.%d" ]
]
end
def title(page_title)
content_for(:title) { page_title }
end
@@ -9,10 +22,6 @@ module ApplicationHelper
content_for(:header_title) { page_title }
end
def permitted_accountable_partial(name)
name.underscore
end
def family_notifications_stream
turbo_stream_from [ Current.family, :notifications ] if Current.family
end
@@ -80,8 +89,8 @@ module ApplicationHelper
color = hex || "#1570EF" # blue-600
<<-STYLE.strip
background-color: color-mix(in srgb, #{color} 5%, white);
border-color: color-mix(in srgb, #{color} 10%, white);
background-color: color-mix(in srgb, #{color} 10%, white);
border-color: color-mix(in srgb, #{color} 30%, white);
color: #{color};
STYLE
end
@@ -136,6 +145,19 @@ module ApplicationHelper
end
end
# Wrapper around I18n.l to support custom date formats
def format_date(object, format = :default, options = {})
date = object.to_date
format_code = options[:format_code] || Current.family&.date_format
if format_code.present?
date.strftime(format_code)
else
I18n.l(date, format: format, **options)
end
end
def format_money(number_or_money, options = {})
return nil unless number_or_money

View File

@@ -4,4 +4,8 @@ module CategoriesHelper
name: "Uncategorized",
color: Category::UNCATEGORIZED_COLOR
end
def family_categories
[ null_category ].concat(Current.family.categories.alphabetically)
end
end

View File

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

View File

@@ -0,0 +1,366 @@
module LanguagesHelper
LANGUAGE_MAPPING = {
en: "English",
ru: "Russian",
ar: "Arabic",
bg: "Bulgarian",
'ca-CAT': "Catalan (Catalonia)",
ca: "Catalan",
'da-DK': "Danish (Denmark)",
'de-AT': "German (Austria)",
'de-CH': "German (Switzerland)",
de: "German",
ee: "Ewe",
'en-AU': "English (Australia)",
'en-BORK': "English (Bork)",
'en-CA': "English (Canada)",
'en-GB': "English (United Kingdom)",
'en-IND': "English (India)",
'en-KE': "English (Kenya)",
'en-MS': "English (Malaysia)",
'en-NEP': "English (Nepal)",
'en-NG': "English (Nigeria)",
'en-NZ': "English (New Zealand)",
'en-PAK': "English (Pakistan)",
'en-SG': "English (Singapore)",
'en-TH': "English (Thailand)",
'en-UG': "English (Uganda)",
'en-US': "English (United States)",
'en-ZA': "English (South Africa)",
'en-au-ocker': "English (Australian Ocker)",
'es-AR': "Spanish (Argentina)",
'es-MX': "Spanish (Mexico)",
es: "Spanish",
fa: "Persian",
'fi-FI': "Finnish (Finland)",
fr: "French",
'fr-CA': "French (Canada)",
'fr-CH': "French (Switzerland)",
he: "Hebrew",
hy: "Armenian",
id: "Indonesian",
it: "Italian",
ja: "Japanese",
ko: "Korean",
lt: "Lithuanian",
lv: "Latvian",
'mi-NZ': "Maori (New Zealand)",
'nb-NO': "Norwegian Bokmål (Norway)",
nl: "Dutch",
'no-NO': "Norwegian (Norway)",
pl: "Polish",
'pt-BR': "Portuguese (Brazil)",
pt: "Portuguese",
sk: "Slovak",
sv: "Swedish",
th: "Thai",
tr: "Turkish",
uk: "Ukrainian",
vi: "Vietnamese",
'zh-CN': "Chinese (Simplified)",
'zh-TW': "Chinese (Traditional)",
af: "Afrikaans",
az: "Azerbaijani",
be: "Belarusian",
bn: "Bengali",
bs: "Bosnian",
cs: "Czech",
cy: "Welsh",
da: "Danish",
'de-DE': "German (Germany)",
dz: "Dzongkha",
'el-CY': "Greek (Cyprus)",
el: "Greek",
'en-CY': "English (Cyprus)",
'en-IE': "English (Ireland)",
'en-IN': "English (India)",
'en-TT': "English (Trinidad and Tobago)",
eo: "Esperanto",
'es-419': "Spanish (Latin America)",
'es-CL': "Spanish (Chile)",
'es-CO': "Spanish (Colombia)",
'es-CR': "Spanish (Costa Rica)",
'es-EC': "Spanish (Ecuador)",
'es-ES': "Spanish (Spain)",
'es-NI': "Spanish (Nicaragua)",
'es-PA': "Spanish (Panama)",
'es-PE': "Spanish (Peru)",
'es-US': "Spanish (United States)",
'es-VE': "Spanish (Venezuela)",
et: "Estonian",
eu: "Basque",
fi: "Finnish",
'fr-FR': "French (France)",
fy: "Western Frisian",
gd: "Scottish Gaelic",
gl: "Galician",
'hi-IN': "Hindi (India)",
hi: "Hindi",
hr: "Croatian",
hu: "Hungarian",
is: "Icelandic",
'it-CH': "Italian (Switzerland)",
ka: "Georgian",
kk: "Kazakh",
km: "Khmer",
kn: "Kannada",
lb: "Luxembourgish",
lo: "Lao",
mg: "Malagasy",
mk: "Macedonian",
ml: "Malayalam",
mn: "Mongolian",
'mr-IN': "Marathi (India)",
ms: "Malay",
nb: "Norwegian Bokmål",
ne: "Nepali",
nn: "Norwegian Nynorsk",
oc: "Occitan",
or: "Odia",
pa: "Punjabi",
rm: "Romansh",
ro: "Romanian",
sc: "Sardinian",
sl: "Slovenian",
sq: "Albanian",
sr: "Serbian",
st: "Southern Sotho",
'sv-FI': "Swedish (Finland)",
'sv-SE': "Swedish (Sweden)",
sw: "Swahili",
ta: "Tamil",
te: "Telugu",
tl: "Tagalog",
tt: "Tatar",
ug: "Uyghur",
ur: "Urdu",
uz: "Uzbek",
wo: "Wolof"
}.freeze
EXCLUDED_LOCALES = [
# Test locales
"en-BORK",
"en-au-ocker",
# Duplicate locales
"fr-FR",
"de-DE",
"hi-IN",
"sv-SE",
"ca-CAT",
"en-US",
"fi-FI",
"en-IND"
].freeze
COUNTRY_MAPPING = {
AF: "Afghanistan",
AL: "Albania",
DZ: "Algeria",
AD: "Andorra",
AO: "Angola",
AG: "Antigua and Barbuda",
AR: "Argentina",
AM: "Armenia",
AU: "Australia",
AT: "Austria",
AZ: "Azerbaijan",
BS: "Bahamas",
BH: "Bahrain",
BD: "Bangladesh",
BB: "Barbados",
BY: "Belarus",
BE: "Belgium",
BZ: "Belize",
BJ: "Benin",
BT: "Bhutan",
BO: "Bolivia",
BA: "Bosnia and Herzegovina",
BW: "Botswana",
BR: "Brazil",
BN: "Brunei",
BG: "Bulgaria",
BF: "Burkina Faso",
BI: "Burundi",
KH: "Cambodia",
CM: "Cameroon",
CA: "Canada",
CV: "Cape Verde",
CF: "Central African Republic",
TD: "Chad",
CL: "Chile",
CN: "China",
CO: "Colombia",
KM: "Comoros",
CG: "Congo",
CD: "Congo, Democratic Republic of the",
CR: "Costa Rica",
CI: "Côte d'Ivoire",
HR: "Croatia",
CU: "Cuba",
CY: "Cyprus",
CZ: "Czech Republic",
DK: "Denmark",
DJ: "Djibouti",
DM: "Dominica",
DO: "Dominican Republic",
EC: "Ecuador",
EG: "Egypt",
SV: "El Salvador",
GQ: "Equatorial Guinea",
ER: "Eritrea",
EE: "Estonia",
ET: "Ethiopia",
FJ: "Fiji",
FI: "Finland",
FR: "France",
GA: "Gabon",
GM: "Gambia",
GE: "Georgia",
DE: "Germany",
GH: "Ghana",
GR: "Greece",
GD: "Grenada",
GT: "Guatemala",
GN: "Guinea",
GW: "Guinea-Bissau",
GY: "Guyana",
HT: "Haiti",
HN: "Honduras",
HU: "Hungary",
IS: "Iceland",
IN: "India",
ID: "Indonesia",
IR: "Iran",
IQ: "Iraq",
IE: "Ireland",
IL: "Israel",
IT: "Italy",
JM: "Jamaica",
JP: "Japan",
JO: "Jordan",
KZ: "Kazakhstan",
KE: "Kenya",
KI: "Kiribati",
KP: "North Korea",
KR: "South Korea",
KW: "Kuwait",
KG: "Kyrgyzstan",
LA: "Laos",
LV: "Latvia",
LB: "Lebanon",
LS: "Lesotho",
LR: "Liberia",
LY: "Libya",
LI: "Liechtenstein",
LT: "Lithuania",
LU: "Luxembourg",
MK: "North Macedonia",
MG: "Madagascar",
MW: "Malawi",
MY: "Malaysia",
MV: "Maldives",
ML: "Mali",
MT: "Malta",
MH: "Marshall Islands",
MR: "Mauritania",
MU: "Mauritius",
MX: "Mexico",
FM: "Micronesia",
MD: "Moldova",
MC: "Monaco",
MN: "Mongolia",
ME: "Montenegro",
MA: "Morocco",
MZ: "Mozambique",
MM: "Myanmar",
NA: "Namibia",
NR: "Nauru",
NP: "Nepal",
NL: "Netherlands",
NZ: "New Zealand",
NI: "Nicaragua",
NE: "Niger",
NG: "Nigeria",
NO: "Norway",
OM: "Oman",
PK: "Pakistan",
PW: "Palau",
PA: "Panama",
PG: "Papua New Guinea",
PY: "Paraguay",
PE: "Peru",
PH: "Philippines",
PL: "Poland",
PT: "Portugal",
QA: "Qatar",
RO: "Romania",
RU: "Russia",
RW: "Rwanda",
KN: "Saint Kitts and Nevis",
LC: "Saint Lucia",
VC: "Saint Vincent and the Grenadines",
WS: "Samoa",
SM: "San Marino",
ST: "Sao Tome and Principe",
SA: "Saudi Arabia",
SN: "Senegal",
RS: "Serbia",
SC: "Seychelles",
SL: "Sierra Leone",
SG: "Singapore",
SK: "Slovakia",
SI: "Slovenia",
SB: "Solomon Islands",
SO: "Somalia",
ZA: "South Africa",
SS: "South Sudan",
ES: "Spain",
LK: "Sri Lanka",
SD: "Sudan",
SR: "Suriname",
SE: "Sweden",
CH: "Switzerland",
SY: "Syria",
TW: "Taiwan",
TJ: "Tajikistan",
TZ: "Tanzania",
TH: "Thailand",
TL: "Timor-Leste",
TG: "Togo",
TO: "Tonga",
TT: "Trinidad and Tobago",
TN: "Tunisia",
TR: "Turkey",
TM: "Turkmenistan",
TV: "Tuvalu",
UG: "Uganda",
UA: "Ukraine",
AE: "United Arab Emirates",
GB: "United Kingdom",
US: "United States",
UY: "Uruguay",
UZ: "Uzbekistan",
VU: "Vanuatu",
VA: "Vatican City",
VE: "Venezuela",
VN: "Vietnam",
YE: "Yemen",
ZM: "Zambia",
ZW: "Zimbabwe"
}.freeze
def country_options
COUNTRY_MAPPING.keys.map { |key| [ COUNTRY_MAPPING[key], key ] }
end
def language_options
I18n.available_locales
.reject { |locale| EXCLUDED_LOCALES.include?(locale.to_s) }
.map do |locale|
label = LANGUAGE_MAPPING[locale.to_sym] || locale.to_s.humanize
[ "#{label} (#{locale})", locale ]
end
.sort_by { |label, locale| label }
end
end

View File

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

View File

@@ -5,10 +5,10 @@ module SettingsHelper
{ name: I18n.t("settings.nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
{ name: I18n.t("settings.nav.billing_label"), path: :settings_billing_path },
{ name: I18n.t("settings.nav.accounts_label"), path: :accounts_path },
{ name: I18n.t("settings.nav.imports_label"), path: :imports_path },
{ name: I18n.t("settings.nav.tags_label"), path: :tags_path },
{ name: I18n.t("settings.nav.categories_label"), path: :categories_path },
{ name: I18n.t("settings.nav.merchants_label"), path: :merchants_path },
{ name: I18n.t("settings.nav.imports_label"), path: :imports_path },
{ name: I18n.t("settings.nav.whats_new_label"), path: :changelog_path },
{ name: I18n.t("settings.nav.feedback_label"), path: :feedback_path }
]

View File

@@ -24,7 +24,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
def select(method, choices, options = {}, html_options = {})
merged_html_options = { class: "form-field__input" }.merge(html_options)
label = build_label(method, options)
label = build_label(method, options.merge(required: merged_html_options[:required]))
field = super(method, choices, options, merged_html_options)
build_styled_field(label, field, options, remove_padding_right: true)
@@ -33,7 +33,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
merged_html_options = { class: "form-field__input" }.merge(html_options)
label = build_label(method, options)
label = build_label(method, options.merge(required: merged_html_options[:required]))
field = super(method, collection, value_method, text_method, options, merged_html_options)
build_styled_field(label, field, options, remove_padding_right: true)
@@ -49,7 +49,12 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
end
def submit(value = nil, options = {})
merged_options = { class: "btn btn--primary w-full" }.merge(options)
default_options = {
data: { turbo_submits_with: "Submitting..." },
class: "btn btn--primary w-full"
}
merged_options = default_options.merge(options)
value, options = nil, value if value.is_a?(Hash)
super(value, merged_options)
end
@@ -68,7 +73,17 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
def build_label(method, options)
return "".html_safe unless options[:label]
return label(method, class: "form-field__label") if options[:label] == true
label(method, options[:label], class: "form-field__label")
label_text = options[:label]
if options[:required]
label_text = @template.safe_join([
label_text == true ? method.to_s.humanize : label_text,
@template.tag.span("*", class: "text-red-500 ml-0.5")
])
end
return label(method, class: "form-field__label") if label_text == true
label(method, label_text, class: "form-field__label")
end
end

View File

@@ -1,3 +1,3 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import "@hotwired/turbo-rails";
import "controllers";

View File

@@ -1,51 +1,51 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="account-collapse"
export default class extends Controller {
static values = { type: String }
initialToggle = false
STORAGE_NAME = "accountCollapseStates"
static values = { type: String };
initialToggle = false;
STORAGE_NAME = "accountCollapseStates";
connect() {
this.element.addEventListener("toggle", this.onToggle)
this.updateFromLocalStorage()
this.element.addEventListener("toggle", this.onToggle);
this.updateFromLocalStorage();
}
disconnect() {
this.element.removeEventListener("toggle", this.onToggle)
this.element.removeEventListener("toggle", this.onToggle);
}
onToggle = () => {
if (this.initialToggle) {
this.initialToggle = false
return
this.initialToggle = false;
return;
}
const items = this.getItemsFromLocalStorage()
const items = this.getItemsFromLocalStorage();
if (items.has(this.typeValue)) {
items.delete(this.typeValue)
items.delete(this.typeValue);
} else {
items.add(this.typeValue)
items.add(this.typeValue);
}
localStorage.setItem(this.STORAGE_NAME, JSON.stringify([...items]))
}
localStorage.setItem(this.STORAGE_NAME, JSON.stringify([...items]));
};
updateFromLocalStorage() {
const items = this.getItemsFromLocalStorage()
const items = this.getItemsFromLocalStorage();
if (items.has(this.typeValue)) {
this.initialToggle = true
this.element.setAttribute("open", "")
this.initialToggle = true;
this.element.setAttribute("open", "");
}
}
getItemsFromLocalStorage() {
try {
const items = localStorage.getItem(this.STORAGE_NAME)
return new Set(items ? JSON.parse(items) : [])
const items = localStorage.getItem(this.STORAGE_NAME);
return new Set(items ? JSON.parse(items) : []);
} catch (error) {
console.error("Error parsing items from localStorage:", error)
return new Set()
console.error("Error parsing items from localStorage:", error);
return new Set();
}
}
}

View File

@@ -1,10 +1,10 @@
import { Application } from "@hotwired/stimulus"
import { Application } from "@hotwired/stimulus";
const application = Application.start()
const application = Application.start();
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
application.debug = false;
window.Stimulus = application;
Turbo.setConfirmMethod((message) => {
const dialog = document.getElementById("turbo-confirm");
@@ -34,10 +34,14 @@ Turbo.setConfirmMethod((message) => {
dialog.showModal();
return new Promise((resolve) => {
dialog.addEventListener("close", () => {
resolve(dialog.returnValue == "confirm")
}, { once: true })
})
})
dialog.addEventListener(
"close",
() => {
resolve(dialog.returnValue === "confirm");
},
{ once: true },
);
});
});
export { application }
export { application };

View File

@@ -24,10 +24,31 @@ export default class extends Controller {
});
}
handleInput = () => {
handleInput = (event) => {
const target = event.target
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
this.element.requestSubmit();
}, 500);
}, this.#debounceTimeout(target));
};
#debounceTimeout(element) {
if(element.dataset.autosubmitDebounceTimeout) {
return Number.parseInt(element.dataset.autosubmitDebounceTimeout);
}
const type = element.type || element.tagName;
switch (type.toLowerCase()) {
case 'input':
case 'textarea':
return 500;
case 'select-one':
case 'select-multiple':
return 0;
default:
return 500;
}
}
}

View File

@@ -1,134 +1,155 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="bulk-select"
export default class extends Controller {
static targets = ["row", "group", "selectionBar", "selectionBarText", "bulkEditDrawerTitle"]
static targets = [
"row",
"group",
"selectionBar",
"selectionBarText",
"bulkEditDrawerTitle",
];
static values = {
resource: String,
selectedIds: { type: Array, default: [] }
}
selectedIds: { type: Array, default: [] },
};
connect() {
document.addEventListener("turbo:load", this._updateView)
document.addEventListener("turbo:load", this._updateView);
this._updateView()
this._updateView();
}
disconnect() {
document.removeEventListener("turbo:load", this._updateView)
document.removeEventListener("turbo:load", this._updateView);
}
bulkEditDrawerTitleTargetConnected(element) {
element.innerText = `Edit ${this.selectedIdsValue.length} ${this.#pluralizedResourceName()}`
element.innerText = `Edit ${
this.selectedIdsValue.length
} ${this._pluralizedResourceName()}`;
}
submitBulkRequest(e) {
const form = e.target.closest("form");
const scope = e.params.scope
this.#addHiddenFormInputsForSelectedIds(form, `${scope}[entry_ids][]`, this.selectedIdsValue)
form.requestSubmit()
const scope = e.params.scope;
this._addHiddenFormInputsForSelectedIds(
form,
`${scope}[entry_ids][]`,
this.selectedIdsValue,
);
form.requestSubmit();
}
togglePageSelection(e) {
if (e.target.checked) {
this.#selectAll()
this._selectAll();
} else {
this.deselectAll()
this.deselectAll();
}
}
toggleGroupSelection(e) {
const group = this.groupTargets.find(group => group.contains(e.target))
const group = this.groupTargets.find((group) => group.contains(e.target));
this.#rowsForGroup(group).forEach(row => {
this._rowsForGroup(group).forEach((row) => {
if (e.target.checked) {
this.#addToSelection(row.dataset.id)
this._addToSelection(row.dataset.id);
} else {
this.#removeFromSelection(row.dataset.id)
this._removeFromSelection(row.dataset.id);
}
})
});
}
toggleRowSelection(e) {
if (e.target.checked) {
this.#addToSelection(e.target.dataset.id)
this._addToSelection(e.target.dataset.id);
} else {
this.#removeFromSelection(e.target.dataset.id)
this._removeFromSelection(e.target.dataset.id);
}
}
deselectAll() {
this.selectedIdsValue = []
this.element.querySelectorAll('input[type="checkbox"]').forEach(el => el.checked = false)
this.selectedIdsValue = [];
this.element.querySelectorAll('input[type="checkbox"]').forEach((el) => {
el.checked = false;
});
}
selectedIdsValueChanged() {
this._updateView()
this._updateView();
}
#addHiddenFormInputsForSelectedIds(form, paramName, transactionIds) {
this.#resetFormInputs(form, paramName);
_addHiddenFormInputsForSelectedIds(form, paramName, transactionIds) {
this._resetFormInputs(form, paramName);
transactionIds.forEach(id => {
transactionIds.forEach((id) => {
const input = document.createElement("input");
input.type = 'hidden'
input.name = paramName
input.value = id
form.appendChild(input)
})
input.type = "hidden";
input.name = paramName;
input.value = id;
form.appendChild(input);
});
}
#resetFormInputs(form, paramName) {
_resetFormInputs(form, paramName) {
const existingInputs = form.querySelectorAll(`input[name='${paramName}']`);
existingInputs.forEach((input) => input.remove());
}
#rowsForGroup(group) {
return this.rowTargets.filter(row => group.contains(row))
_rowsForGroup(group) {
return this.rowTargets.filter((row) => group.contains(row));
}
#addToSelection(idToAdd) {
_addToSelection(idToAdd) {
this.selectedIdsValue = Array.from(
new Set([...this.selectedIdsValue, idToAdd])
)
new Set([...this.selectedIdsValue, idToAdd]),
);
}
#removeFromSelection(idToRemove) {
this.selectedIdsValue = this.selectedIdsValue.filter(id => id !== idToRemove)
_removeFromSelection(idToRemove) {
this.selectedIdsValue = this.selectedIdsValue.filter(
(id) => id !== idToRemove,
);
}
#selectAll() {
this.selectedIdsValue = this.rowTargets.map(t => t.dataset.id)
_selectAll() {
this.selectedIdsValue = this.rowTargets.map((t) => t.dataset.id);
}
_updateView = () => {
this.#updateSelectionBar()
this.#updateGroups()
this.#updateRows()
this._updateSelectionBar();
this._updateGroups();
this._updateRows();
};
_updateSelectionBar() {
const count = this.selectedIdsValue.length;
this.selectionBarTextTarget.innerText = `${count} ${this._pluralizedResourceName()} selected`;
this.selectionBarTarget.hidden = count === 0;
this.selectionBarTarget.querySelector("input[type='checkbox']").checked =
count > 0;
}
#updateSelectionBar() {
const count = this.selectedIdsValue.length
this.selectionBarTextTarget.innerText = `${count} ${this.#pluralizedResourceName()} selected`
this.selectionBarTarget.hidden = count === 0
this.selectionBarTarget.querySelector("input[type='checkbox']").checked = count > 0
_pluralizedResourceName() {
return `${this.resourceValue}${
this.selectedIdsValue.length === 1 ? "" : "s"
}`;
}
#pluralizedResourceName() {
return `${this.resourceValue}${this.selectedIdsValue.length === 1 ? "" : "s"}`
_updateGroups() {
this.groupTargets.forEach((group) => {
const rows = this.rowTargets.filter((row) => group.contains(row));
const groupSelected =
rows.length > 0 &&
rows.every((row) => this.selectedIdsValue.includes(row.dataset.id));
group.querySelector("input[type='checkbox']").checked = groupSelected;
});
}
#updateGroups() {
this.groupTargets.forEach(group => {
const rows = this.rowTargets.filter(row => group.contains(row))
const groupSelected = rows.length > 0 && rows.every(row => this.selectedIdsValue.includes(row.dataset.id))
group.querySelector("input[type='checkbox']").checked = groupSelected
})
}
#updateRows() {
this.rowTargets.forEach(row => {
row.checked = this.selectedIdsValue.includes(row.dataset.id)
})
_updateRows() {
this.rowTargets.forEach((row) => {
row.checked = this.selectedIdsValue.includes(row.dataset.id);
});
}
}

View File

@@ -1,28 +1,28 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["source", "iconDefault", "iconSuccess"]
static targets = ["source", "iconDefault", "iconSuccess"];
copy(event) {
event.preventDefault();
if (this.sourceTarget && this.sourceTarget.textContent) {
navigator.clipboard.writeText(this.sourceTarget.textContent)
if (this.sourceTarget?.textContent) {
navigator.clipboard
.writeText(this.sourceTarget.textContent)
.then(() => {
this.showSuccess();
})
.catch((error) => {
console.error('Failed to copy text: ', error);
console.error("Failed to copy text: ", error);
});
}
}
showSuccess() {
this.iconDefaultTarget.classList.add('hidden');
this.iconSuccessTarget.classList.remove('hidden');
this.iconDefaultTarget.classList.add("hidden");
this.iconSuccessTarget.classList.remove("hidden");
setTimeout(() => {
this.iconDefaultTarget.classList.remove('hidden');
this.iconSuccessTarget.classList.add('hidden');
this.iconDefaultTarget.classList.remove("hidden");
this.iconSuccessTarget.classList.add("hidden");
}, 3000);
}
}

View File

@@ -3,10 +3,7 @@ import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="color-avatar"
// Used by the transaction merchant form to show a preview of what the avatar will look like
export default class extends Controller {
static targets = [
"name",
"avatar"
];
static targets = ["name", "avatar"];
connect() {
this.nameTarget.addEventListener("input", this.handleNameChange);
@@ -17,8 +14,10 @@ export default class extends Controller {
}
handleNameChange = (e) => {
this.avatarTarget.textContent = (e.currentTarget.value?.[0] || "?").toUpperCase();
}
this.avatarTarget.textContent = (
e.currentTarget.value?.[0] || "?"
).toUpperCase();
};
handleColorChange(e) {
const color = e.currentTarget.value;
@@ -26,4 +25,4 @@ export default class extends Controller {
this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`;
this.avatarTarget.style.color = color;
}
}
}

View File

@@ -1,59 +1,65 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = [ "input", "decoration" ]
static values = { selection: String }
static targets = ["input", "decoration"];
static values = { selection: String };
connect() {
this.#renderOptions()
this.#renderOptions();
}
select({ target }) {
this.selectionValue = target.dataset.value
this.selectionValue = target.dataset.value;
}
selectionValueChanged() {
this.#options.forEach(option => {
this.#options.forEach((option) => {
if (option.dataset.value === this.selectionValue) {
this.#check(option)
this.inputTarget.value = this.selectionValue
this.#check(option);
this.inputTarget.value = this.selectionValue;
} else {
this.#uncheck(option)
this.#uncheck(option);
}
})
});
}
#renderOptions() {
this.#options.forEach(option => option.style.backgroundColor = option.dataset.value)
this.#options.forEach((option) => {
option.style.backgroundColor = option.dataset.value;
});
}
#check(option) {
option.setAttribute("aria-checked", "true")
option.style.boxShadow = `0px 0px 0px 4px ${hexToRGBA(option.dataset.value, 0.2)}`
this.decorationTarget.style.backgroundColor = option.dataset.value
option.setAttribute("aria-checked", "true");
option.style.boxShadow = `0px 0px 0px 4px ${hexToRGBA(
option.dataset.value,
0.2,
)}`;
this.decorationTarget.style.backgroundColor = option.dataset.value;
}
#uncheck(option) {
option.setAttribute("aria-checked", "false")
option.style.boxShadow = "none"
option.setAttribute("aria-checked", "false");
option.style.boxShadow = "none";
}
get #options() {
return Array.from(this.element.querySelectorAll("[role='radio']"))
return Array.from(this.element.querySelectorAll("[role='radio']"));
}
}
function hexToRGBA(hex, alpha = 1) {
hex = hex.replace(/^#/, '');
let hexCode = hex.replace(/^#/, "");
let calculatedAlpha = alpha;
if (hex.length === 8) {
alpha = parseInt(hex.slice(6, 8), 16) / 255;
hex = hex.slice(0, 6);
if (hexCode.length === 8) {
calculatedAlpha = Number.parseInt(hexCode.slice(6, 8), 16) / 255;
hexCode = hexCode.slice(0, 6);
}
let r = parseInt(hex.slice(0, 2), 16);
let g = parseInt(hex.slice(2, 4), 16);
let b = parseInt(hex.slice(4, 6), 16);
const r = Number.parseInt(hexCode.slice(0, 2), 16);
const g = Number.parseInt(hexCode.slice(2, 4), 16);
const b = Number.parseInt(hexCode.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
return `rgba(${r}, ${g}, ${b}, ${calculatedAlpha})`;
}

View File

@@ -1,30 +1,30 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["replacementField", "submitButton"]
static classes = [ "dangerousAction", "safeAction" ]
static targets = ["replacementField", "submitButton"];
static classes = ["dangerousAction", "safeAction"];
static values = {
submitTextWhenReplacing: String,
submitTextWhenNotReplacing: String
}
submitTextWhenNotReplacing: String,
};
updateSubmitButton() {
if (this.replacementFieldTarget.value) {
this.submitButtonTarget.value = this.submitTextWhenReplacingValue
this.#markSafe()
this.submitButtonTarget.value = this.submitTextWhenReplacingValue;
this.#markSafe();
} else {
this.submitButtonTarget.value = this.submitTextWhenNotReplacingValue
this.#markDangerous()
this.submitButtonTarget.value = this.submitTextWhenNotReplacingValue;
this.#markDangerous();
}
}
#markSafe() {
this.submitButtonTarget.classList.remove(...this.dangerousActionClasses)
this.submitButtonTarget.classList.add(...this.safeActionClasses)
this.submitButtonTarget.classList.remove(...this.dangerousActionClasses);
this.submitButtonTarget.classList.add(...this.safeActionClasses);
}
#markDangerous() {
this.submitButtonTarget.classList.remove(...this.safeActionClasses)
this.submitButtonTarget.classList.add(...this.dangerousActionClasses)
this.submitButtonTarget.classList.remove(...this.safeActionClasses);
this.submitButtonTarget.classList.add(...this.dangerousActionClasses);
}
}

View File

@@ -1,9 +1,8 @@
import { Controller } from '@hotwired/stimulus'
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="element-removal"
export default class extends Controller {
remove() {
this.element.remove()
this.element.remove();
}
}

View File

@@ -1,5 +1,5 @@
import { Controller } from "@hotwired/stimulus";
import { install, uninstall } from "@github/hotkey";
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="hotkey"
export default class extends Controller {

View File

@@ -1,10 +1,10 @@
// Import and register all your controllers from the importmap under controllers/*
import { application } from "controllers/application"
import { application } from "controllers/application";
// Eager load all controllers defined in the import map under controllers/**/*_controller
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading";
eagerLoadControllersFrom("controllers", application);
// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"

View File

@@ -1,39 +1,40 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="list-keyboard-navigation"
export default class extends Controller {
focusPrevious() {
this.focusLinkTargetInDirection(-1)
this.focusLinkTargetInDirection(-1);
}
focusNext() {
this.focusLinkTargetInDirection(1)
this.focusLinkTargetInDirection(1);
}
focusLinkTargetInDirection(direction) {
const element = this.getLinkTargetInDirection(direction)
element?.focus()
const element = this.getLinkTargetInDirection(direction);
element?.focus();
}
getLinkTargetInDirection(direction) {
const indexOfLastFocus = this.indexOfLastFocus()
let nextIndex = (indexOfLastFocus + direction) % this.focusableLinks.length
if (nextIndex < 0) nextIndex = this.focusableLinks.length - 1
return this.focusableLinks[nextIndex]
const indexOfLastFocus = this.indexOfLastFocus();
let nextIndex = (indexOfLastFocus + direction) % this.focusableLinks.length;
if (nextIndex < 0) nextIndex = this.focusableLinks.length - 1;
return this.focusableLinks[nextIndex];
}
indexOfLastFocus(targets = this.focusableLinks) {
const indexOfActiveElement = targets.indexOf(document.activeElement)
const indexOfActiveElement = targets.indexOf(document.activeElement);
if (indexOfActiveElement !== -1) {
return indexOfActiveElement
} else {
return targets.findIndex(target => target.getAttribute("tabindex") === "0")
return indexOfActiveElement;
}
return targets.findIndex(
(target) => target.getAttribute("tabindex") === "0",
);
}
get focusableLinks() {
return Array.from(this.element.querySelectorAll("a[href]"))
return Array.from(this.element.querySelectorAll("a[href]"));
}
}

View File

@@ -1,5 +1,11 @@
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import { Controller } from "@hotwired/stimulus";
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';
/**
* A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms.
@@ -70,8 +76,10 @@ export default class extends Controller {
}
focusFirstElement() {
const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const firstFocusableElement = this.contentTarget.querySelectorAll(focusableElements)[0];
const focusableElements =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const firstFocusableElement =
this.contentTarget.querySelectorAll(focusableElements)[0];
if (firstFocusableElement) {
firstFocusableElement.focus();
}
@@ -79,7 +87,11 @@ export default class extends Controller {
startAutoUpdate() {
if (!this._cleanup) {
this._cleanup = autoUpdate(this.buttonTarget, this.contentTarget, this.boundUpdate);
this._cleanup = autoUpdate(
this.buttonTarget,
this.contentTarget,
this.boundUpdate,
);
}
}
@@ -93,14 +105,10 @@ export default class extends Controller {
update() {
computePosition(this.buttonTarget, this.contentTarget, {
placement: this.placementValue,
middleware: [
offset(this.offsetValue),
flip(),
shift({ padding: 5 })
],
middleware: [offset(this.offsetValue), flip(), shift({ padding: 5 })],
}).then(({ x, y }) => {
Object.assign(this.contentTarget.style, {
position: 'fixed',
position: "fixed",
left: `${x}px`,
top: `${y}px`,
});

View File

@@ -1,10 +1,10 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="modal"
export default class extends Controller {
connect() {
if (this.element.open) return
else this.element.showModal()
if (this.element.open) return;
this.element.showModal();
}
// Hide the dialog when the user clicks outside of it

View File

@@ -12,14 +12,16 @@ export default class extends Controller {
}
updateAmount(currency) {
(new CurrenciesService).get(currency).then((currency) => {
new CurrenciesService().get(currency).then((currency) => {
this.amountTarget.step = currency.step;
if (isFinite(this.amountTarget.value)) {
this.amountTarget.value = parseFloat(this.amountTarget.value).toFixed(currency.default_precision)
if (Number.isFinite(this.amountTarget.value)) {
this.amountTarget.value = Number.parseFloat(
this.amountTarget.value,
).toFixed(currency.default_precision);
}
this.symbolTarget.innerText = currency.symbol;
});
}
}
}

View File

@@ -0,0 +1,29 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="onboarding"
export default class extends Controller {
setLocale(event) {
this.refreshWithParam("locale", event.target.value);
}
setDateFormat(event) {
this.refreshWithParam("date_format", event.target.value);
}
setCurrency(event) {
this.refreshWithParam("currency", event.target.value);
}
refreshWithParam(key, value) {
const url = new URL(window.location);
url.searchParams.set(key, value);
// Preserve existing params by getting the current search string
// and appending our new param to it
const currentParams = new URLSearchParams(window.location.search);
currentParams.set(key, value);
// Refresh the page with all params
window.location.search = currentParams.toString();
}
}

View File

@@ -104,27 +104,25 @@ export default class extends Controller {
}
get #d3Svg() {
if (this.#d3SvgMemo) {
return this.#d3SvgMemo;
} else {
return (this.#d3SvgMemo = this.#createMainSvg());
if (!this.#d3SvgMemo) {
this.#d3SvgMemo = this.#createMainSvg();
}
return this.#d3SvgMemo;
}
get #d3Group() {
if (this.#d3GroupMemo) {
return this.#d3GroupMemo;
} else {
return (this.#d3GroupMemo = this.#createMainGroup());
if (!this.#d3GroupMemo) {
this.#d3GroupMemo = this.#createMainGroup();
}
return this.#d3GroupMemo;
}
get #d3Content() {
if (this.#d3ContentMemo) {
return this.#d3ContentMemo;
} else {
return (this.#d3ContentMemo = this.#createContent());
if (!this.#d3ContentMemo) {
this.#d3ContentMemo = this.#createContent();
}
return this.#d3ContentMemo;
}
#createMainSvg() {

View File

@@ -1,27 +1,35 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["imagePreview", "fileField", "deleteField", "clearBtn", "template"]
static targets = [
"attachedImage",
"previewImage",
"placeholderImage",
"deleteProfileImage",
"input",
"clearBtn",
];
preview(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-full h-full rounded-full object-cover" />`;
this.templateTarget.classList.add("hidden");
this.clearBtnTarget.classList.remove("hidden");
};
reader.readAsDataURL(file);
}
clearFileInput() {
this.inputTarget.value = null;
this.clearBtnTarget.classList.add("hidden");
this.placeholderImageTarget.classList.remove("hidden");
this.attachedImageTarget.classList.add("hidden");
this.previewImageTarget.classList.add("hidden");
this.deleteProfileImageTarget.value = "1";
}
clear() {
this.deleteFieldTarget.value = true;
this.fileFieldTarget.value = null;
this.templateTarget.classList.remove("hidden");
this.imagePreviewTarget.innerHTML = this.templateTarget.innerHTML;
this.clearBtnTarget.classList.add("hidden");
this.element.submit();
showFileInputPreview(event) {
const file = event.target.files[0];
if (!file) return;
this.placeholderImageTarget.classList.add("hidden");
this.attachedImageTarget.classList.add("hidden");
this.previewImageTarget.classList.remove("hidden");
this.clearBtnTarget.classList.remove("hidden");
this.deleteProfileImageTarget.value = "0";
this.previewImageTarget.querySelector("img").src =
URL.createObjectURL(file);
}
}

View File

@@ -28,7 +28,7 @@ export default class extends Controller {
updateClasses = (selectedId) => {
this.btnTargets.forEach((btn) =>
btn.classList.remove(...this.activeClasses)
btn.classList.remove(...this.activeClasses),
);
this.tabTargets.forEach((tab) => tab.classList.add("hidden"));

View File

@@ -1,6 +1,6 @@
import { Controller } from "@hotwired/stimulus"
import tailwindColors from "@maybe/tailwindcolors"
import * as d3 from "d3"
import { Controller } from "@hotwired/stimulus";
import tailwindColors from "@maybe/tailwindcolors";
import * as d3 from "d3";
export default class extends Controller {
static values = {
@@ -8,247 +8,259 @@ export default class extends Controller {
strokeWidth: { type: Number, default: 2 },
useLabels: { type: Boolean, default: true },
useTooltip: { type: Boolean, default: true },
usePercentSign: Boolean
}
usePercentSign: Boolean,
};
#d3SvgMemo = null;
#d3GroupMemo = null;
#d3Tooltip = null;
#d3InitialContainerWidth = 0;
#d3InitialContainerHeight = 0;
#normalDataPoints = [];
_d3SvgMemo = null;
_d3GroupMemo = null;
_d3Tooltip = null;
_d3InitialContainerWidth = 0;
_d3InitialContainerHeight = 0;
_normalDataPoints = [];
connect() {
this.#install()
document.addEventListener("turbo:load", this.#reinstall)
this._install();
document.addEventListener("turbo:load", this._reinstall);
}
disconnect() {
this.#teardown()
document.removeEventListener("turbo:load", this.#reinstall)
this._teardown();
document.removeEventListener("turbo:load", this._reinstall);
}
_reinstall = () => {
this._teardown();
this._install();
};
#reinstall = () => {
this.#teardown()
this.#install()
_teardown() {
this._d3SvgMemo = null;
this._d3GroupMemo = null;
this._d3Tooltip = null;
this._normalDataPoints = [];
this._d3Container.selectAll("*").remove();
}
#teardown() {
this.#d3SvgMemo = null
this.#d3GroupMemo = null
this.#d3Tooltip = null
this.#normalDataPoints = []
this.#d3Container.selectAll("*").remove()
_install() {
this._normalizeDataPoints();
this._rememberInitialContainerSize();
this._draw();
}
#install() {
this.#normalizeDataPoints()
this.#rememberInitialContainerSize()
this.#draw()
}
#normalizeDataPoints() {
this.#normalDataPoints = (this.dataValue.values || []).map((d) => ({
_normalizeDataPoints() {
this._normalDataPoints = (this.dataValue.values || []).map((d) => ({
...d,
date: new Date(d.date),
date: this._parseDate(d.date),
value: d.value.amount ? +d.value.amount : +d.value,
currency: d.value.currency
}))
currency: d.value.currency,
}));
}
#rememberInitialContainerSize() {
this.#d3InitialContainerWidth = this.#d3Container.node().clientWidth
this.#d3InitialContainerHeight = this.#d3Container.node().clientHeight
_parseDate(dateString) {
const [year, month, day] = dateString.split("-").map(Number);
return new Date(year, month - 1, day);
}
_rememberInitialContainerSize() {
this._d3InitialContainerWidth = this._d3Container.node().clientWidth;
this._d3InitialContainerHeight = this._d3Container.node().clientHeight;
}
#draw() {
if (this.#normalDataPoints.length < 2) {
this.#drawEmpty()
_draw() {
if (this._normalDataPoints.length < 2) {
this._drawEmpty();
} else {
this.#drawChart()
this._drawChart();
}
}
_drawEmpty() {
this._d3Svg.selectAll(".tick").remove();
this._d3Svg.selectAll(".domain").remove();
#drawEmpty() {
this.#d3Svg.selectAll(".tick").remove()
this.#d3Svg.selectAll(".domain").remove()
this.#drawDashedLineEmptyState()
this.#drawCenteredCircleEmptyState()
this._drawDashedLineEmptyState();
this._drawCenteredCircleEmptyState();
}
#drawDashedLineEmptyState() {
this.#d3Svg
_drawDashedLineEmptyState() {
this._d3Svg
.append("line")
.attr("x1", this.#d3InitialContainerWidth / 2)
.attr("x1", this._d3InitialContainerWidth / 2)
.attr("y1", 0)
.attr("x2", this.#d3InitialContainerWidth / 2)
.attr("y2", this.#d3InitialContainerHeight)
.attr("x2", this._d3InitialContainerWidth / 2)
.attr("y2", this._d3InitialContainerHeight)
.attr("stroke", tailwindColors.gray[300])
.attr("stroke-dasharray", "4, 4")
.attr("stroke-dasharray", "4, 4");
}
#drawCenteredCircleEmptyState() {
this.#d3Svg
_drawCenteredCircleEmptyState() {
this._d3Svg
.append("circle")
.attr("cx", this.#d3InitialContainerWidth / 2)
.attr("cy", this.#d3InitialContainerHeight / 2)
.attr("cx", this._d3InitialContainerWidth / 2)
.attr("cy", this._d3InitialContainerHeight / 2)
.attr("r", 4)
.style("fill", tailwindColors.gray[400])
.style("fill", tailwindColors.gray[400]);
}
#drawChart() {
this.#drawTrendline()
_drawChart() {
this._drawTrendline();
if (this.useLabelsValue) {
this.#drawXAxisLabels()
this.#drawGradientBelowTrendline()
this._drawXAxisLabels();
this._drawGradientBelowTrendline();
}
if (this.useTooltipValue) {
this.#drawTooltip()
this.#trackMouseForShowingTooltip()
this._drawTooltip();
this._trackMouseForShowingTooltip();
}
}
#drawTrendline() {
this.#installTrendlineSplit()
_drawTrendline() {
this._installTrendlineSplit();
this.#d3Group
this._d3Group
.append("path")
.datum(this.#normalDataPoints)
.datum(this._normalDataPoints)
.attr("fill", "none")
.attr("stroke", `url(#${this.element.id}-split-gradient)`)
.attr("d", this.#d3Line)
.attr("d", this._d3Line)
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("stroke-width", this.strokeWidthValue)
.attr("stroke-width", this.strokeWidthValue);
}
#installTrendlineSplit() {
const gradient = this.#d3Svg
_installTrendlineSplit() {
const gradient = this._d3Svg
.append("defs")
.append("linearGradient")
.attr("id", `${this.element.id}-split-gradient`)
.attr("gradientUnits", "userSpaceOnUse")
.attr("x1", this.#d3XScale.range()[0])
.attr("x2", this.#d3XScale.range()[1])
.attr("x1", this._d3XScale.range()[0])
.attr("x2", this._d3XScale.range()[1]);
gradient.append("stop")
gradient
.append("stop")
.attr("class", "start-color")
.attr("offset", "0%")
.attr("stop-color", this.#trendColor)
.attr("stop-color", this._trendColor);
gradient.append("stop")
gradient
.append("stop")
.attr("class", "middle-color")
.attr("offset", "100%")
.attr("stop-color", this.#trendColor)
.attr("stop-color", this._trendColor);
gradient.append("stop")
gradient
.append("stop")
.attr("class", "end-color")
.attr("offset", "100%")
.attr("stop-color", tailwindColors.gray[300])
.attr("stop-color", tailwindColors.gray[300]);
}
#setTrendlineSplitAt(percent) {
this.#d3Svg
_setTrendlineSplitAt(percent) {
this._d3Svg
.select(`#${this.element.id}-split-gradient`)
.select(".middle-color")
.attr("offset", `${percent * 100}%`)
.attr("offset", `${percent * 100}%`);
this.#d3Svg
this._d3Svg
.select(`#${this.element.id}-split-gradient`)
.select(".end-color")
.attr("offset", `${percent * 100}%`)
.attr("offset", `${percent * 100}%`);
this.#d3Svg
this._d3Svg
.select(`#${this.element.id}-trendline-gradient-rect`)
.attr("width", this.#d3ContainerWidth * percent)
.attr("width", this._d3ContainerWidth * percent);
}
#drawXAxisLabels() {
_drawXAxisLabels() {
// Add ticks
this.#d3Group
this._d3Group
.append("g")
.attr("transform", `translate(0,${this.#d3ContainerHeight})`)
.attr("transform", `translate(0,${this._d3ContainerHeight})`)
.call(
d3
.axisBottom(this.#d3XScale)
.tickValues([this.#normalDataPoints[0].date, this.#normalDataPoints[this.#normalDataPoints.length - 1].date])
.axisBottom(this._d3XScale)
.tickValues([
this._normalDataPoints[0].date,
this._normalDataPoints[this._normalDataPoints.length - 1].date,
])
.tickSize(0)
.tickFormat(d3.timeFormat("%d %b %Y"))
.tickFormat(d3.timeFormat("%d %b %Y")),
)
.select(".domain")
.remove()
.remove();
// Style ticks
this.#d3Group.selectAll(".tick text")
this._d3Group
.selectAll(".tick text")
.style("fill", tailwindColors.gray[500])
.style("font-size", "12px")
.style("font-weight", "500")
.attr("text-anchor", "middle")
.attr("dx", (_d, i) => {
// We know we only have 2 values
return i === 0 ? "5em" : "-5em"
return i === 0 ? "5em" : "-5em";
})
.attr("dy", "0em")
.attr("dy", "0em");
}
#drawGradientBelowTrendline() {
_drawGradientBelowTrendline() {
// Define gradient
const gradient = this.#d3Group
const gradient = this._d3Group
.append("defs")
.append("linearGradient")
.attr("id", `${this.element.id}-trendline-gradient`)
.attr("gradientUnits", "userSpaceOnUse")
.attr("x1", 0)
.attr("x2", 0)
.attr("y1", this.#d3YScale(d3.max(this.#normalDataPoints, d => d.value)))
.attr("y2", this.#d3ContainerHeight)
.attr(
"y1",
this._d3YScale(d3.max(this._normalDataPoints, (d) => d.value)),
)
.attr("y2", this._d3ContainerHeight);
gradient
.append("stop")
.attr("offset", 0)
.attr("stop-color", this.#trendColor)
.attr("stop-opacity", 0.06)
.attr("stop-color", this._trendColor)
.attr("stop-opacity", 0.06);
gradient
.append("stop")
.attr("offset", 0.5)
.attr("stop-color", this.#trendColor)
.attr("stop-opacity", 0)
.attr("stop-color", this._trendColor)
.attr("stop-opacity", 0);
// Clip path makes gradient start at the trendline
this.#d3Group
this._d3Group
.append("clipPath")
.attr("id", `${this.element.id}-clip-below-trendline`)
.append("path")
.datum(this.#normalDataPoints)
.attr("d", d3.area()
.x(d => this.#d3XScale(d.date))
.y0(this.#d3ContainerHeight)
.y1(d => this.#d3YScale(d.value))
)
.datum(this._normalDataPoints)
.attr(
"d",
d3
.area()
.x((d) => this._d3XScale(d.date))
.y0(this._d3ContainerHeight)
.y1((d) => this._d3YScale(d.value)),
);
// Apply the gradient + clip path
this.#d3Group
this._d3Group
.append("rect")
.attr("id", `${this.element.id}-trendline-gradient-rect`)
.attr("width", this.#d3ContainerWidth)
.attr("height", this.#d3ContainerHeight)
.attr("width", this._d3ContainerWidth)
.attr("height", this._d3ContainerHeight)
.attr("clip-path", `url(#${this.element.id}-clip-below-trendline)`)
.style("fill", `url(#${this.element.id}-trendline-gradient)`)
.style("fill", `url(#${this.element.id}-trendline-gradient)`);
}
#drawTooltip() {
this.#d3Tooltip = d3
_drawTooltip() {
this._d3Tooltip = d3
.select(`#${this.element.id}`)
.append("div")
.style("position", "absolute")
@@ -258,93 +270,102 @@ export default class extends Controller {
.style("border", `1px solid ${tailwindColors["alpha-black"][100]}`)
.style("border-radius", "10px")
.style("pointer-events", "none")
.style("opacity", 0) // Starts as hidden
.style("opacity", 0); // Starts as hidden
}
#trackMouseForShowingTooltip() {
const bisectDate = d3.bisector(d => d.date).left
_trackMouseForShowingTooltip() {
const bisectDate = d3.bisector((d) => d.date).left;
this.#d3Group
this._d3Group
.append("rect")
.attr("width", this.#d3ContainerWidth)
.attr("height", this.#d3ContainerHeight)
.attr("width", this._d3ContainerWidth)
.attr("height", this._d3ContainerHeight)
.attr("fill", "none")
.attr("pointer-events", "all")
.on("mousemove", (event) => {
const estimatedTooltipWidth = 250
const pageWidth = document.body.clientWidth
const tooltipX = event.pageX + 10
const overflowX = tooltipX + estimatedTooltipWidth - pageWidth
const adjustedX = overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX
const estimatedTooltipWidth = 250;
const pageWidth = document.body.clientWidth;
const tooltipX = event.pageX + 10;
const overflowX = tooltipX + estimatedTooltipWidth - pageWidth;
const adjustedX =
overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX;
const [xPos] = d3.pointer(event)
const x0 = bisectDate(this.#normalDataPoints, this.#d3XScale.invert(xPos), 1)
const d0 = this.#normalDataPoints[x0 - 1]
const d1 = this.#normalDataPoints[x0]
const d = xPos - this.#d3XScale(d0.date) > this.#d3XScale(d1.date) - xPos ? d1 : d0
const xPercent = this.#d3XScale(d.date) / this.#d3ContainerWidth
const [xPos] = d3.pointer(event);
const x0 = bisectDate(
this._normalDataPoints,
this._d3XScale.invert(xPos),
1,
);
const d0 = this._normalDataPoints[x0 - 1];
const d1 = this._normalDataPoints[x0];
const d =
xPos - this._d3XScale(d0.date) > this._d3XScale(d1.date) - xPos
? d1
: d0;
const xPercent = this._d3XScale(d.date) / this._d3ContainerWidth;
this.#setTrendlineSplitAt(xPercent)
this._setTrendlineSplitAt(xPercent);
// Reset
this.#d3Group.selectAll(".data-point-circle").remove()
this.#d3Group.selectAll(".guideline").remove()
this._d3Group.selectAll(".data-point-circle").remove();
this._d3Group.selectAll(".guideline").remove();
// Guideline
this.#d3Group
this._d3Group
.append("line")
.attr("class", "guideline")
.attr("x1", this.#d3XScale(d.date))
.attr("x1", this._d3XScale(d.date))
.attr("y1", 0)
.attr("x2", this.#d3XScale(d.date))
.attr("y2", this.#d3ContainerHeight)
.attr("x2", this._d3XScale(d.date))
.attr("y2", this._d3ContainerHeight)
.attr("stroke", tailwindColors.gray[300])
.attr("stroke-dasharray", "4, 4")
.attr("stroke-dasharray", "4, 4");
// Big circle
this.#d3Group
this._d3Group
.append("circle")
.attr("class", "data-point-circle")
.attr("cx", this.#d3XScale(d.date))
.attr("cy", this.#d3YScale(d.value))
.attr("cx", this._d3XScale(d.date))
.attr("cy", this._d3YScale(d.value))
.attr("r", 8)
.attr("fill", this.#trendColor)
.attr("fill", this._trendColor)
.attr("fill-opacity", "0.1")
.attr("pointer-events", "none")
.attr("pointer-events", "none");
// Small circle
this.#d3Group
this._d3Group
.append("circle")
.attr("class", "data-point-circle")
.attr("cx", this.#d3XScale(d.date))
.attr("cy", this.#d3YScale(d.value))
.attr("cx", this._d3XScale(d.date))
.attr("cy", this._d3YScale(d.value))
.attr("r", 3)
.attr("fill", this.#trendColor)
.attr("pointer-events", "none")
.attr("fill", this._trendColor)
.attr("pointer-events", "none");
// Render tooltip
this.#d3Tooltip
.html(this.#tooltipTemplate(d))
this._d3Tooltip
.html(this._tooltipTemplate(d))
.style("opacity", 1)
.style("z-index", 999)
.style("left", adjustedX + "px")
.style("top", event.pageY - 10 + "px")
.style("left", `${adjustedX}px`)
.style("top", `${event.pageY - 10}px`);
})
.on("mouseout", (event) => {
const hoveringOnGuideline = event.toElement?.classList.contains("guideline")
const hoveringOnGuideline =
event.toElement?.classList.contains("guideline");
if (!hoveringOnGuideline) {
this.#d3Group.selectAll(".guideline").remove()
this.#d3Group.selectAll(".data-point-circle").remove()
this.#d3Tooltip.style("opacity", 0)
this._d3Group.selectAll(".guideline").remove();
this._d3Group.selectAll(".data-point-circle").remove();
this._d3Tooltip.style("opacity", 0);
this.#setTrendlineSplitAt(1)
this._setTrendlineSplitAt(1);
}
})
});
}
#tooltipTemplate(datum) {
return (`
_tooltipTemplate(datum) {
return `
<div style="margin-bottom: 4px; color: ${tailwindColors.gray[500]};">
${d3.timeFormat("%b %d, %Y")(datum.date)}
</div>
@@ -356,164 +377,172 @@ export default class extends Controller {
cx="5"
cy="5"
r="4"
stroke="${this.#tooltipTrendColor(datum)}"
stroke="${this._tooltipTrendColor(datum)}"
fill="transparent"
stroke-width="1"></circle>
</svg>
${this.#tooltipValue(datum)}${this.usePercentSignValue ? "%" : ""}
${this._tooltipValue(datum)}${this.usePercentSignValue ? "%" : ""}
</div>
${this.usePercentSignValue || datum.trend.value === 0 || datum.trend.value.amount === 0 ? `
${
this.usePercentSignValue ||
datum.trend.value === 0 ||
datum.trend.value.amount === 0
? `
<span style="width: 80px;"></span>
` : `
<span style="color: ${this.#tooltipTrendColor(datum)};">
${this.#tooltipChange(datum)} (${datum.trend.percent}%)
`
: `
<span style="color: ${this._tooltipTrendColor(datum)};">
${this._tooltipChange(datum)} (${datum.trend.percent}%)
</span>
`}
`
}
</div>
`)
`;
}
#tooltipTrendColor(datum) {
_tooltipTrendColor(datum) {
return {
up: tailwindColors.success,
down: tailwindColors.error,
flat: tailwindColors.gray[500],
}[datum.trend.direction]
}[datum.trend.direction];
}
#tooltipValue(datum) {
_tooltipValue(datum) {
if (datum.currency) {
return this.#currencyValue(datum)
} else {
return datum.value
return this._currencyValue(datum);
}
return datum.value;
}
#tooltipChange(datum) {
_tooltipChange(datum) {
if (datum.currency) {
return this.#currencyChange(datum)
} else {
return this.#decimalChange(datum)
return this._currencyChange(datum);
}
return this._decimalChange(datum);
}
#currencyValue(datum) {
_currencyValue(datum) {
return Intl.NumberFormat(undefined, {
style: "currency",
currency: datum.currency,
}).format(datum.value)
}).format(datum.value);
}
#currencyChange(datum) {
_currencyChange(datum) {
return Intl.NumberFormat(undefined, {
style: "currency",
currency: datum.currency,
signDisplay: "always",
}).format(datum.trend.value.amount)
}).format(datum.trend.value.amount);
}
#decimalChange(datum) {
_decimalChange(datum) {
return Intl.NumberFormat(undefined, {
style: "decimal",
signDisplay: "always",
}).format(datum.trend.value)
}).format(datum.trend.value);
}
#createMainSvg() {
return this.#d3Container
_createMainSvg() {
return this._d3Container
.append("svg")
.attr("width", this.#d3InitialContainerWidth)
.attr("height", this.#d3InitialContainerHeight)
.attr("viewBox", [0, 0, this.#d3InitialContainerWidth, this.#d3InitialContainerHeight])
.attr("width", this._d3InitialContainerWidth)
.attr("height", this._d3InitialContainerHeight)
.attr("viewBox", [
0,
0,
this._d3InitialContainerWidth,
this._d3InitialContainerHeight,
]);
}
#createMainGroup() {
return this.#d3Svg
_createMainGroup() {
return this._d3Svg
.append("g")
.attr("transform", `translate(${this.#margin.left},${this.#margin.top})`)
.attr("transform", `translate(${this._margin.left},${this._margin.top})`);
}
get #d3Svg() {
if (this.#d3SvgMemo) {
return this.#d3SvgMemo
} else {
return this.#d3SvgMemo = this.#createMainSvg()
get _d3Svg() {
if (!this._d3SvgMemo) {
this._d3SvgMemo = this._createMainSvg();
}
return this._d3SvgMemo;
}
get #d3Group() {
if (this.#d3GroupMemo) {
return this.#d3GroupMemo
} else {
return this.#d3GroupMemo = this.#createMainGroup()
get _d3Group() {
if (!this._d3GroupMemo) {
this._d3GroupMemo = this._createMainGroup();
}
return this._d3GroupMemo;
}
get #margin() {
get _margin() {
if (this.useLabelsValue) {
return { top: 20, right: 0, bottom: 30, left: 0 }
} else {
return { top: 0, right: 0, bottom: 0, left: 0 }
return { top: 20, right: 0, bottom: 30, left: 0 };
}
return { top: 0, right: 0, bottom: 0, left: 0 };
}
get #d3ContainerWidth() {
return this.#d3InitialContainerWidth - this.#margin.left - this.#margin.right
get _d3ContainerWidth() {
return (
this._d3InitialContainerWidth - this._margin.left - this._margin.right
);
}
get #d3ContainerHeight() {
return this.#d3InitialContainerHeight - this.#margin.top - this.#margin.bottom
get _d3ContainerHeight() {
return (
this._d3InitialContainerHeight - this._margin.top - this._margin.bottom
);
}
get #d3Container() {
return d3.select(this.element)
get _d3Container() {
return d3.select(this.element);
}
get #trendColor() {
if (this.#trendDirection === "flat") {
return tailwindColors.gray[500]
} else if (this.#trendDirection === this.#favorableDirection) {
return tailwindColors.green[500]
} else {
return tailwindColors.error
get _trendColor() {
if (this._trendDirection === "flat") {
return tailwindColors.gray[500];
}
if (this._trendDirection === this._favorableDirection) {
return tailwindColors.green[500];
}
return tailwindColors.error;
}
get #trendDirection() {
return this.dataValue.trend.direction
get _trendDirection() {
return this.dataValue.trend.direction;
}
get #favorableDirection() {
return this.dataValue.trend.favorable_direction
get _favorableDirection() {
return this.dataValue.trend.favorable_direction;
}
get #d3Line() {
get _d3Line() {
return d3
.line()
.x(d => this.#d3XScale(d.date))
.y(d => this.#d3YScale(d.value))
.x((d) => this._d3XScale(d.date))
.y((d) => this._d3YScale(d.value));
}
get #d3XScale() {
get _d3XScale() {
return d3
.scaleTime()
.rangeRound([0, this.#d3ContainerWidth])
.domain(d3.extent(this.#normalDataPoints, d => d.date))
.rangeRound([0, this._d3ContainerWidth])
.domain(d3.extent(this._normalDataPoints, (d) => d.date));
}
get #d3YScale() {
const reductionPercent = this.useLabelsValue ? 0.15 : 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
get _d3YScale() {
const reductionPercent = this.useLabelsValue ? 0.15 : 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;
return d3
.scaleLinear()
.rangeRound([this.#d3ContainerHeight, 0])
.domain([dataMin - padding, dataMax + padding])
.rangeRound([this._d3ContainerHeight, 0])
.domain([dataMin - padding, dataMax + padding]);
}
}

View File

@@ -1,11 +1,11 @@
import { Controller } from '@hotwired/stimulus'
import {
autoUpdate,
computePosition,
flip,
shift,
offset,
autoUpdate
} from '@floating-ui/dom';
shift,
} from "@floating-ui/dom";
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["tooltip"];
@@ -39,20 +39,20 @@ export default class extends Controller {
}
show = () => {
this.tooltipTarget.style.display = 'block';
this.tooltipTarget.style.display = "block";
this.update(); // Ensure immediate update when shown
}
};
hide = () => {
this.tooltipTarget.style.display = 'none';
}
this.tooltipTarget.style.display = "none";
};
startAutoUpdate() {
if (!this._cleanup) {
this._cleanup = autoUpdate(
this.element,
this.tooltipTarget,
this.boundUpdate
this.boundUpdate,
);
}
}
@@ -69,9 +69,13 @@ export default class extends Controller {
computePosition(this.element, this.tooltipTarget, {
placement: this.placementValue,
middleware: [
offset({ mainAxis: this.offsetValue, crossAxis: this.crossAxisValue, alignmentAxis: this.alignmentAxisValue }),
offset({
mainAxis: this.offsetValue,
crossAxis: this.crossAxisValue,
alignmentAxis: this.alignmentAxisValue,
}),
flip(),
shift({ padding: 5 })
shift({ padding: 5 }),
],
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(this.tooltipTarget.style, {
@@ -80,4 +84,4 @@ export default class extends Controller {
});
});
}
}
}

View File

@@ -1,55 +1,62 @@
import {Controller} from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
const TRADE_TYPES = {
BUY: "buy",
SELL: "sell",
TRANSFER_IN: "transfer_in",
TRANSFER_OUT: "transfer_out",
INTEREST: "interest"
}
INTEREST: "interest",
};
const FIELD_VISIBILITY = {
[TRADE_TYPES.BUY]: {ticker: true, qty: true, price: true},
[TRADE_TYPES.SELL]: {ticker: true, qty: true, price: true},
[TRADE_TYPES.TRANSFER_IN]: {amount: true, transferAccount: true},
[TRADE_TYPES.TRANSFER_OUT]: {amount: true, transferAccount: true},
[TRADE_TYPES.INTEREST]: {amount: true}
}
[TRADE_TYPES.BUY]: { ticker: true, qty: true, price: true },
[TRADE_TYPES.SELL]: { ticker: true, qty: true, price: true },
[TRADE_TYPES.TRANSFER_IN]: { amount: true, transferAccount: true },
[TRADE_TYPES.TRANSFER_OUT]: { amount: true, transferAccount: true },
[TRADE_TYPES.INTEREST]: { amount: true },
};
// Connects to data-controller="trade-form"
export default class extends Controller {
static targets = ["typeInput", "tickerInput", "amountInput", "transferAccountInput", "qtyInput", "priceInput"]
static targets = [
"typeInput",
"tickerInput",
"amountInput",
"transferAccountInput",
"qtyInput",
"priceInput",
];
connect() {
this.handleTypeChange = this.handleTypeChange.bind(this)
this.typeInputTarget.addEventListener("change", this.handleTypeChange)
this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY)
this.handleTypeChange = this.handleTypeChange.bind(this);
this.typeInputTarget.addEventListener("change", this.handleTypeChange);
this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY);
}
disconnect() {
this.typeInputTarget.removeEventListener("change", this.handleTypeChange)
this.typeInputTarget.removeEventListener("change", this.handleTypeChange);
}
handleTypeChange(event) {
this.updateFields(event.target.value)
this.updateFields(event.target.value);
}
updateFields(type) {
const visibleFields = FIELD_VISIBILITY[type] || {}
const visibleFields = FIELD_VISIBILITY[type] || {};
Object.entries(this.fieldTargets).forEach(([field, target]) => {
const isVisible = visibleFields[field] || false
const isVisible = visibleFields[field] || false;
// Update visibility
target.hidden = !isVisible
target.hidden = !isVisible;
// Update required status based on visibility
if (isVisible) {
target.setAttribute('required', '')
target.setAttribute("required", "");
} else {
target.removeAttribute('required')
target.removeAttribute("required");
}
})
});
}
get fieldTargets() {
@@ -58,7 +65,7 @@ export default class extends Controller {
amount: this.amountInputTarget,
transferAccount: this.transferAccountInputTarget,
qty: this.qtyInputTarget,
price: this.priceInputTarget
}
price: this.priceInputTarget,
};
}
}
}

View File

@@ -0,0 +1,13 @@
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,7 +1,10 @@
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
@@ -27,6 +30,8 @@ class Account < ApplicationRecord
scope :alphabetically, -> { order(:name) }
scope :ungrouped, -> { where(institution_id: nil) }
has_one_attached :logo
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
accepts_nested_attributes_for :accountable

View File

@@ -0,0 +1,57 @@
class Account::Balance::Calculator
def initialize(account, sync_start_date)
@account = account
@sync_start_date = sync_start_date
end
def calculate(is_partial_sync: false)
cached_entries = account.entries.where("date >= ?", sync_start_date).to_a
sync_starting_balance = is_partial_sync ? find_start_balance_for_partial_sync : find_start_balance_for_full_sync(cached_entries)
prior_balance = sync_starting_balance
(sync_start_date..Date.current).map do |date|
current_balance = calculate_balance_for_date(date, entries: cached_entries, prior_balance:)
prior_balance = current_balance
build_balance(date, current_balance)
end
end
private
attr_reader :account, :sync_start_date
def find_start_balance_for_partial_sync
account.balances.find_by(currency: account.currency, date: sync_start_date - 1.day)&.balance
end
def find_start_balance_for_full_sync(cached_entries)
account.balance + net_entry_flows(cached_entries.select { |e| e.account_transaction? })
end
def calculate_balance_for_date(date, entries:, prior_balance:)
valuation = entries.find { |e| e.date == date && e.account_valuation? }
return valuation.amount if valuation
entries = entries.select { |e| e.date == date }
prior_balance - net_entry_flows(entries)
end
def net_entry_flows(entries, target_currency = account.currency)
converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
flows = converted_entry_amounts.sum(&:amount)
account.liability? ? flows * -1 : flows
end
def build_balance(date, balance, currency = nil)
account.balances.build \
date: date,
balance: balance,
currency: currency || account.currency
end
end

View File

@@ -0,0 +1,46 @@
class Account::Balance::Converter
def initialize(account, sync_start_date)
@account = account
@sync_start_date = sync_start_date
end
def convert(balances)
calculate_converted_balances(balances)
end
private
attr_reader :account, :sync_start_date
def calculate_converted_balances(balances)
from_currency = account.currency
to_currency = account.family.currency
if ExchangeRate.exchange_rates_provider.nil?
account.observe_missing_exchange_rate_provider
return []
end
exchange_rates = ExchangeRate.find_rates from: from_currency,
to: to_currency,
start_date: sync_start_date
missing_exchange_rates = balances.map(&:date) - exchange_rates.map(&:date)
if missing_exchange_rates.any?
account.observe_missing_exchange_rates(from: from_currency, to: to_currency, dates: missing_exchange_rates)
return []
end
balances.map do |balance|
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency)
end
end
def build_balance(date, balance, currency = nil)
account.balances.build \
date: date,
balance: balance,
currency: currency || account.currency
end
end

View File

@@ -0,0 +1,37 @@
class Account::Balance::Loader
def initialize(account)
@account = account
end
def load(balances, start_date)
Account::Balance.transaction do
upsert_balances!(balances)
purge_stale_balances!(start_date)
account.reload
update_account_balance!(balances)
end
end
private
attr_reader :account
def update_account_balance!(balances)
last_balance = balances.select { |db| db.currency == account.currency }.last&.balance
account.update! balance: last_balance if last_balance.present?
end
def upsert_balances!(balances)
current_time = Time.now
balances_to_upsert = balances.map do |balance|
balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time)
end
account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency])
end
def purge_stale_balances!(start_date)
account.balances.delete_by("date < ?", start_date)
end
end

View File

@@ -1,133 +1,51 @@
class Account::Balance::Syncer
def initialize(account, start_date: nil)
@account = account
@provided_start_date = start_date
@sync_start_date = calculate_sync_start_date(start_date)
@loader = Account::Balance::Loader.new(account)
@converter = Account::Balance::Converter.new(account, sync_start_date)
@calculator = Account::Balance::Calculator.new(account, sync_start_date)
end
def run
daily_balances = calculate_daily_balances
daily_balances += calculate_converted_balances(daily_balances) if account.currency != account.family.currency
daily_balances = calculator.calculate(is_partial_sync: is_partial_sync?)
daily_balances += converter.convert(daily_balances) if account.currency != account.family.currency
Account::Balance.transaction do
upsert_balances!(daily_balances)
purge_stale_balances!
if daily_balances.any?
account.reload
last_balance = daily_balances.select { |db| db.currency == account.currency }.last&.balance
account.update! balance: last_balance
end
end
loader.load(daily_balances, account_start_date)
rescue Money::ConversionError => e
account.observe_missing_exchange_rates(from: e.from_currency, to: e.to_currency, dates: [ e.date ])
end
private
attr_reader :sync_start_date, :account
def upsert_balances!(balances)
current_time = Time.now
balances_to_upsert = balances.map do |balance|
balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time)
end
account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency])
end
def purge_stale_balances!
account.balances.delete_by("date < ?", account_start_date)
end
def calculate_balance_for_date(date, entries:, prior_balance:)
valuation = entries.find { |e| e.date == date && e.account_valuation? }
return valuation.amount if valuation
return derived_sync_start_balance(entries) unless prior_balance
entries = entries.select { |e| e.date == date }
prior_balance - net_entry_flows(entries)
end
def calculate_daily_balances
entries = account.entries.where("date >= ?", sync_start_date).to_a
prior_balance = find_prior_balance
(sync_start_date..Date.current).map do |date|
current_balance = calculate_balance_for_date(date, entries:, prior_balance:)
prior_balance = current_balance
build_balance(date, current_balance)
end
end
def calculate_converted_balances(balances)
from_currency = account.currency
to_currency = account.family.currency
if ExchangeRate.exchange_rates_provider.nil?
account.observe_missing_exchange_rate_provider
return []
end
exchange_rates = ExchangeRate.find_rates from: from_currency,
to: to_currency,
start_date: sync_start_date
missing_exchange_rates = balances.map(&:date) - exchange_rates.map(&:date)
if missing_exchange_rates.any?
account.observe_missing_exchange_rates(from: from_currency, to: to_currency, dates: missing_exchange_rates)
return []
end
balances.map do |balance|
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency)
end
end
def build_balance(date, balance, currency = nil)
account.balances.build \
date: date,
balance: balance,
currency: currency || account.currency
end
def derived_sync_start_balance(entries)
transactions_and_trades = entries.reject { |e| e.account_valuation? }.select { |e| e.date > sync_start_date }
account.balance + net_entry_flows(transactions_and_trades)
end
def find_prior_balance
account.balances.where(currency: account.currency).where("date < ?", sync_start_date).order(date: :desc).first&.balance
end
def net_entry_flows(entries, target_currency = account.currency)
converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
flows = converted_entry_amounts.sum(&:amount)
account.liability? ? flows * -1 : flows
end
attr_reader :sync_start_date, :provided_start_date, :account, :loader, :converter, :calculator
def account_start_date
@account_start_date ||= begin
oldest_entry_date = account.entries.chronological.first.try(:date)
oldest_entry = account.entries.chronological.first
return Date.current unless oldest_entry_date
return Date.current unless oldest_entry.present?
oldest_entry_is_valuation = account.entries.account_valuations.where(date: oldest_entry_date).exists?
oldest_entry_date -= 1 unless oldest_entry_is_valuation
oldest_entry_date
if oldest_entry.account_valuation?
oldest_entry.date
else
oldest_entry.date - 1.day
end
end
end
def calculate_sync_start_date(provided_start_date)
[ provided_start_date, account_start_date ].compact.max
return provided_start_date if provided_start_date.present? && prior_balance_available?(provided_start_date)
account_start_date
end
def prior_balance_available?(date)
account.balances.find_by(currency: account.currency, date: date - 1.day).present?
end
def is_partial_sync?
sync_start_date == provided_start_date && sync_start_date < Date.current
end
end

View File

@@ -67,7 +67,7 @@ class Account::Entry < ApplicationRecord
class << self
# arbitrary cutoff date to avoid expensive sync operations
def min_supported_date
10.years.ago.to_date
20.years.ago.to_date
end
def daily_totals(entries, currency, period: Period.last_30_days)

View File

@@ -13,7 +13,19 @@ class Account::Transaction < ApplicationRecord
class << self
def search(params)
query = all
query = query.joins(:category).where(categories: { name: params[:categories] }) if params[:categories].present?
if params[:categories].present?
if params[:categories].exclude?("Uncategorized")
query = query
.joins(:category)
.where(categories: { name: params[:categories] })
else
query = query
.left_joins(:category)
.where(categories: { name: params[:categories] })
.or(query.where(category_id: nil))
end
end
query = query.joins(:merchant).where(merchants: { name: params[:merchants] }) if params[:merchants].present?
if params[:tags].present?

View File

@@ -14,6 +14,14 @@ class AccountImport < Import
)
account.save!
account.entries.create!(
amount: row.amount,
currency: row.currency,
date: Date.current,
name: "Imported account value",
entryable: Account::Valuation.new
)
end
end
end

View File

@@ -4,6 +4,7 @@ class Category < ApplicationRecord
belongs_to :family
validates :name, :color, :family, presence: true
validates :name, uniqueness: { scope: :family_id }
before_update :clear_internal_category, if: :name_changed?

View File

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

View File

@@ -12,4 +12,8 @@ class CreditCard < ApplicationRecord
def annual_fee_money
annual_fee ? Money.new(annual_fee, account.currency) : nil
end
def color
"#F13636"
end
end

View File

@@ -1,3 +1,7 @@
class Crypto < ApplicationRecord
include Accountable
def color
"#737373"
end
end

View File

@@ -1,7 +1,19 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session
attribute :user_agent, :ip_address
delegate :user, to: :session, allow_nil: true
attribute :session
delegate :family, to: :user, allow_nil: true
def user
impersonated_user || session&.user
end
def impersonated_user
session&.active_impersonator_session&.impersonated
end
def true_user
session&.user
end
end

View File

@@ -71,7 +71,8 @@ class Demo::Generator
first_name: "Demo",
last_name: "User",
role: "admin",
password: "password"
password: "password",
onboarded_at: Time.current
end
def create_tags!

View File

@@ -1,3 +1,7 @@
class Depository < ApplicationRecord
include Accountable
def color
"#875BF7"
end
end

View File

@@ -52,7 +52,9 @@ module ExchangeRate::Provided
rate: response.rate,
date: date
rate.save! if cache
if cache
rate.save! rescue ActiveRecord::RecordNotUnique
end
rate
else
nil

View File

@@ -1,4 +1,6 @@
class Family < ApplicationRecord
DATE_FORMATS = [ "%m-%d-%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%m/%d/%Y", "%e/%m/%Y", "%Y.%m.%d" ]
include Providable
has_many :users, dependent: :destroy
@@ -13,6 +15,7 @@ class Family < ApplicationRecord
has_many :issues, through: :accounts
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :date_format, inclusion: { in: DATE_FORMATS }
def snapshot(period = Period.all)
query = accounts.active.joins(:balances)
@@ -129,7 +132,7 @@ class Family < ApplicationRecord
end
def subscribed?
stripe_subscription_status.present? && stripe_subscription_status == "active"
stripe_subscription_status == "active"
end
def primary_user

View File

@@ -0,0 +1,39 @@
class ImpersonationSession < ApplicationRecord
belongs_to :impersonator, class_name: "User"
belongs_to :impersonated, class_name: "User"
has_many :logs, class_name: "ImpersonationSessionLog"
enum :status, { pending: "pending", in_progress: "in_progress", complete: "complete", rejected: "rejected" }
scope :initiated, -> { where(status: [ :pending, :in_progress ]) }
validate :impersonator_is_super_admin
validate :impersonated_is_not_super_admin
validate :impersonator_different_from_impersonated
def approve!
update! status: :in_progress
end
def reject!
update! status: :rejected
end
def complete!
update! status: :complete
end
private
def impersonator_is_super_admin
errors.add(:impersonator, "must be a super admin to impersonate") unless impersonator.super_admin?
end
def impersonated_is_not_super_admin
errors.add(:impersonated, "cannot be a super admin") if impersonated.super_admin?
end
def impersonator_different_from_impersonated
errors.add(:impersonator, "cannot be the same as the impersonated user") if impersonator == impersonated
end
end

View File

@@ -0,0 +1,3 @@
class ImpersonationSessionLog < ApplicationRecord
belongs_to :impersonation_session
end

View File

@@ -46,4 +46,8 @@ class Investment < ApplicationRecord
rescue Money::ConversionError
TimeSeries.new([])
end
def color
"#1570EF"
end
end

View File

@@ -16,4 +16,8 @@ class Loan < ApplicationRecord
Money.new(payment.round, account.currency)
end
def color
"#D444F1"
end
end

View File

@@ -3,6 +3,7 @@ class Merchant < ApplicationRecord
belongs_to :family
validates :name, :color, :family, presence: true
validates :name, uniqueness: { scope: :family }
scope :alphabetically, -> { order(:name) }

View File

@@ -1,3 +1,11 @@
class OtherAsset < ApplicationRecord
include Accountable
def color
"#12B76A"
end
def mode_required?
false
end
end

View File

@@ -1,3 +1,11 @@
class OtherLiability < ApplicationRecord
include Accountable
def color
"#737373"
end
def mode_required?
false
end
end

View File

@@ -19,6 +19,14 @@ class Property < ApplicationRecord
TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount)
end
def color
"#06AED4"
end
def mode_required?
false
end
private
def first_valuation_amount
account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money

View File

@@ -0,0 +1,119 @@
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

@@ -0,0 +1,27 @@
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,5 +1,9 @@
class Session < ApplicationRecord
belongs_to :user
belongs_to :active_impersonator_session,
-> { where(status: :in_progress) },
class_name: "ImpersonationSession",
optional: true
before_create do
self.user_agent = Current.user_agent

View File

@@ -0,0 +1,3 @@
class StockExchange < ApplicationRecord
scope :in_country, ->(country_code) { where(country_code: country_code) }
end

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