Compare commits

...

108 Commits

Author SHA1 Message Date
Josh Pigford
dd1a7fa8cb Improve holding records generation with security preloading 2025-02-03 11:18:27 -06:00
scodes73
f57fa526af Fix: make date format year consistent overall #1712 (#1726)
* Fix: make date format year consistent overall

* chore: Consolidating all date formatting options

* adding disabled condition back to mint import

* chore: Moving formats from helper to models/family.rd

* Adding date_format_label to the en translation for import/configurations

* nit: making changes to use individual translations
2025-02-03 11:19:56 -05:00
Jacco Broeren
b02380ac97 Fix: unable to add accounts without plain set up (#1769)
* Update family.rb

Fix for: https://github.com/maybe-finance/maybe/issues/1766

Signed-off-by: Jacco Broeren <jaccobroeren@freedom.nl>

* Update family.rb

Add commenting.

Signed-off-by: Jacco Broeren <jaccobroeren@freedom.nl>

---------

Signed-off-by: Jacco Broeren <jaccobroeren@freedom.nl>
2025-02-03 11:18:49 -05:00
dependabot[bot]
550991e240 Bump sentry-rails from 5.22.2 to 5.22.3 (#1772)
Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 5.22.2 to 5.22.3.
- [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.22.2...5.22.3)

---
updated-dependencies:
- dependency-name: sentry-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>
2025-02-03 10:49:13 -05:00
Josh Pigford
4a768d0358 Clean up timezone selector 2025-02-03 09:46:40 -06:00
Josh Pigford
a1065fde83 Updated "small" image variant for retina 2025-02-03 09:08:43 -06:00
dependabot[bot]
f63aea7f87 Bump sentry-ruby from 5.22.2 to 5.22.3 (#1773)
Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 5.22.2 to 5.22.3.
- [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.22.2...5.22.3)

---
updated-dependencies:
- dependency-name: sentry-ruby
  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>
2025-02-03 10:05:52 -05:00
dependabot[bot]
872a480c0f Bump puma from 6.5.0 to 6.6.0 (#1774)
Bumps [puma](https://github.com/puma/puma) from 6.5.0 to 6.6.0.
- [Release notes](https://github.com/puma/puma/releases)
- [Changelog](https://github.com/puma/puma/blob/master/History.md)
- [Commits](https://github.com/puma/puma/compare/v6.5.0...v6.6.0)

---
updated-dependencies:
- dependency-name: puma
  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>
2025-02-03 10:05:40 -05:00
Josh Pigford
1620d56e2d Allow connecting a few accounts before upgrade prompt 2025-02-03 09:04:39 -06:00
dependabot[bot]
c1e48bd3c9 Bump plaid from 35.0.0 to 35.1.0 (#1776)
Bumps [plaid](https://github.com/plaid/plaid-ruby) from 35.0.0 to 35.1.0.
- [Release notes](https://github.com/plaid/plaid-ruby/releases)
- [Changelog](https://github.com/plaid/plaid-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/plaid/plaid-ruby/compare/v35.0.0...v35.1.0)

---
updated-dependencies:
- dependency-name: plaid
  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>
2025-02-03 09:51:15 -05:00
dependabot[bot]
4c083fec0a Bump stripe from 13.4.0 to 13.4.1 (#1777)
Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.4.0 to 13.4.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.4.0...v13.4.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>
2025-02-03 09:51:02 -05:00
Zach Gollwitzer
2c2b600163 Improve speed of transactions page (#1752)
* Make demo data more realistic

* Fix N+1 transactions query

* Lint fixes

* Totals query

* Consolidate stats calcs

* Fix preload

* Fix filter clearing

* Fix N+1 queries for family sync detection

* Reduce queries for rendering transfers

* Fix tests

* Remove flaky test
2025-01-31 19:08:21 -05:00
Zach Gollwitzer
53f4b32c33 Fix EU plaid flow (#1761)
* Fix EU plaid flow

* Fix failing tests
2025-01-31 17:04:26 -05:00
Josh Pigford
4bf72506d5 Initial pass at Plaid EU (#1555)
* Initial pass at Plaid EU

* Add EU support to Plaid Items

* Lint

* Temp fix for rubocop isseus

* Merge cleanup

* Pass in region and get tests passing

* Use absolute path for translation

---------

Signed-off-by: Josh Pigford <josh@joshpigford.com>
2025-01-31 12:13:58 -06:00
Josh Pigford
41873de11d Allow users to update their email address (#1745)
* Change email address

* Email confirmation

* Email change test

* Lint

* Schema reset

* Set test email sender

* Select specific user fixture

* Refactor/cleanup

* Remove unused email_confirmation_token

* Current user would never be true

* Fix translation test failures
2025-01-31 11:29:49 -06:00
Josh Pigford
46e86a9a11 Pass in user role to Intercom 2025-01-31 10:34:20 -06:00
Julien Bertazzo Lambert
ad5b0b8b7d Ensure Consistent Category Colors (#1722)
* feat: add validation to require consistent category color

* feat: reflect color requirement in new category form

* refactor: move logic inline over shared component

* rubocop

* tests: fix breaking and add case for new validation

* feat: hide color selector when parent category selected

* feat: override color with parent color in model

* tests: remove case for unnecessary validation

---------

Signed-off-by: Julien Bertazzo Lambert <42924425+JLambertazzo@users.noreply.github.com>
2025-01-30 16:49:31 -05:00
Zach Gollwitzer
ded42a8c33 Add back txn logos
This reverts commit b78fd1d755.
2025-01-30 15:31:16 -05:00
Zach Gollwitzer
b78fd1d755 Temporarily disable txn logos for performance 2025-01-30 14:17:25 -05:00
Josh Pigford
0696e1f2f7 Add/remove members and invitations (#1744)
* Add/remove members and invitations

* Lint
2025-01-30 13:13:37 -06:00
Zach Gollwitzer
282c05345d Preserve transaction filters and transaction focus across page visits (#1733)
* Preserve transaction filters across page visits

* Preserve params when per_page is updated

* Autofocus selected transactions

* Lint fixes

* Fix syntax error

* Fix filter clearing

* Update e2e tests for new UI

* Consolidate focus behavior into concern

* Lint fixes
2025-01-30 14:12:01 -05:00
Elvis Serrão
0b17976256 Don't allow a subcategory to be assigned to another subcategory to ensure 1 level of nesting max (#1730)
* Improve category level limit validation

* Set categories list only for non parents

* Disable select field

* Add info about the disabled select

* Don’t render a select input for parent categories

* Handle correctly turbo_stream request format

* Add turbo_stream format to requests on create and update action's tests

* Remove no_content status from update action

* Revert "Remove no_content status from update action"

This reverts commit 866140c196.

* Revert "Add turbo_stream format to requests on create and update action's tests"

This reverts commit c6bf21490f.

* Add correct redirect url for both html and turbo_stream formats

* Remove useless turbo_frame_tag
2025-01-30 12:35:30 -05:00
Zach Gollwitzer
3b0f8ae8c2 Only build armv7 on official releases (#1732) 2025-01-28 14:08:04 -05:00
Zach Gollwitzer
247d91b99d Lazy load synth logos (#1731) 2025-01-28 12:03:43 -05:00
Zach Gollwitzer
d428a1f954 Bump to Ruby 3.4.1 (#1721) 2025-01-27 19:59:16 -05:00
Jestin Palamuttam
8256d116dd fix: category update sync (#1720) 2025-01-27 19:58:45 -05:00
Zach Gollwitzer
de90b29201 Add RejectedTransfer model, simplify auto matching (#1690)
* Allow transfers to match when inflow is after outflow

* Simplify transfer auto matching with RejectedTransfer model

* Validations

* Reset migrations
2025-01-27 16:56:46 -05:00
Zach Gollwitzer
0b4e314f58 Bump bundler version, address Docker build failures 2025-01-27 16:29:30 -05:00
Zach Gollwitzer
6c8974a086 Update render.yaml
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2025-01-27 13:18:02 -05:00
Eirik H
7265f58518 Add cabin / cottage as a property type (#1658)
* Add cabin / cottage as property type

Signed-off-by: Eirik H <post@eirikh.no>

* Update app/models/property.rb

Signed-off-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>

---------

Signed-off-by: Eirik H <post@eirikh.no>
Signed-off-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
2025-01-27 13:04:36 -05:00
Nikhil Badyal
2a202576f8 Added more periods (#1714) 2025-01-27 13:03:15 -05:00
Georgi Tapalilov
d2a7aef6ef fix n+1 for categories (#1693) 2025-01-27 09:34:13 -05:00
Harshit Chaudhary
eabfb7aae1 Added Decimal Support in min transaction (#1681)
* Added Decimal Support in min transaction

* fix: Using inbuilt money field

* Updated Test
2025-01-27 09:33:56 -05:00
dependabot[bot]
2a1b5fab1a Bump erb_lint from 0.8.0 to 0.9.0 (#1704)
Bumps [erb_lint](https://github.com/Shopify/erb-lint) from 0.8.0 to 0.9.0.
- [Release notes](https://github.com/Shopify/erb-lint/releases)
- [Commits](https://github.com/Shopify/erb-lint/compare/v0.8.0...v0.9.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>
2025-01-27 09:30:19 -05:00
dependabot[bot]
5cc592d38f Bump plaid from 34.0.0 to 35.0.0 (#1707)
Bumps [plaid](https://github.com/plaid/plaid-ruby) from 34.0.0 to 35.0.0.
- [Release notes](https://github.com/plaid/plaid-ruby/releases)
- [Changelog](https://github.com/plaid/plaid-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/plaid/plaid-ruby/compare/v34.0.0...v35.0.0)

---
updated-dependencies:
- dependency-name: plaid
  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>
2025-01-27 09:30:01 -05:00
Julien Bertazzo Lambert
8be5bb07c8 fix: reuse correct expense total calculation in budget.rb (#1699) 2025-01-27 09:29:50 -05:00
dependabot[bot]
caf359deed Bump ruby-lsp-rails from 0.3.30 to 0.3.31 (#1703)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.30 to 0.3.31.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.30...v0.3.31)

---
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>
2025-01-27 09:22:39 -05:00
dependabot[bot]
91149ceff8 Bump tailwindcss-rails from 3.3.0 to 3.3.1 (#1708)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 3.3.0 to 3.3.1.
- [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/v3.3.0...v3.3.1)

---
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>
2025-01-27 09:22:23 -05:00
dependabot[bot]
f9d4270a75 Bump good_job from 4.7.0 to 4.8.2 (#1709)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.7.0 to 4.8.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.7.0...v4.8.2)

---
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>
2025-01-27 09:21:47 -05:00
dependabot[bot]
beb6e36577 Bump selenium-webdriver from 4.27.0 to 4.28.0 (#1710)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.27.0 to 4.28.0.
- [Release notes](https://github.com/SeleniumHQ/selenium/releases)
- [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES)
- [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.27.0...selenium-4.28.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-27 09:21:38 -05:00
dependabot[bot]
217a96c02d Bump sentry-rails from 5.22.1 to 5.22.2 (#1711)
Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 5.22.1 to 5.22.2.
- [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.22.1...5.22.2)

---
updated-dependencies:
- dependency-name: sentry-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>
2025-01-27 09:21:29 -05:00
Zach Gollwitzer
e617d791d3 Show budget averages in family currency
Fixes #1689
2025-01-24 20:19:13 -05:00
Zach Gollwitzer
7e0ec4bd8f Report good job connection errors to Sentry 2025-01-24 13:52:40 -05:00
Zach Gollwitzer
3140835f28 Adjust queues to prioritize account syncs (#1682) 2025-01-24 13:39:08 -05:00
Zach Gollwitzer
7d04ea1071 Update issue templates 2025-01-24 09:18:52 -05:00
Zach Gollwitzer
43dd16e3fb Only update account balance if changed (#1676)
* Only update balance if changed

* Update test assertions
2025-01-23 21:14:01 -05:00
Tony Vincent
61321f6b16 fix: Only admins can generate invite codes (#1611)
* fix: Only admins can generate invite codes

* fix: raise error if user is not an admin when creating invite codesss
2025-01-23 20:47:51 -05:00
Josh Pigford
0476f25952 Rollback AWS SDK version to address checksum conflicts 2025-01-23 10:22:53 -06:00
Josh Pigford
e4a374772a Increased expiration time on storage to prevent broken images as well as implement a fix for R2/S3 conflicts. 2025-01-23 10:11:09 -06:00
Josh Pigford
44961f3628 Only pass in a country code on securities searches if the user location is set to "US" 2025-01-23 10:02:48 -06:00
Zach Gollwitzer
68c570eed8 Make tags scrollable 2025-01-21 12:42:51 -05:00
Zach Gollwitzer
67d81f866f Align cascade delete behavior for transfers (#1647)
* Align cascade delete behavior for transfers

* Lint fix
2025-01-20 16:17:40 -05:00
Zach Gollwitzer
72fd177707 Do not raise on Plaid item not found exceptions for item deletions (#1646) 2025-01-20 15:12:53 -05:00
Zach Gollwitzer
abccba3947 Fix account deletion cascade bug (#1644)
* Fix account deletion cascade bug

* Rubocop fixes
2025-01-20 11:37:01 -05:00
dependabot[bot]
9808641110 Bump ruby-lsp-rails from 0.3.29 to 0.3.30 (#1640)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.29 to 0.3.30.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.29...v0.3.30)

---
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>
2025-01-20 09:45:09 -05:00
dependabot[bot]
9fadc6ba63 Bump stripe from 13.3.0 to 13.3.1 (#1639)
Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.3.0 to 13.3.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.3.0...v13.3.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>
2025-01-20 09:45:00 -05:00
dependabot[bot]
39139ce21a Bump aws-sdk-s3 from 1.177.0 to 1.178.0 (#1638)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.177.0 to 1.178.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>
2025-01-20 09:36:18 -05:00
dependabot[bot]
51e8fae26d Bump stackprof from 0.2.26 to 0.2.27 (#1637)
Bumps [stackprof](https://github.com/tmm1/stackprof) from 0.2.26 to 0.2.27.
- [Changelog](https://github.com/tmm1/stackprof/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tmm1/stackprof/compare/v0.2.26...v0.2.27)

---
updated-dependencies:
- dependency-name: stackprof
  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>
2025-01-20 09:36:07 -05:00
dependabot[bot]
42d2197ea1 Bump intercom-rails from 1.0.5 to 1.0.6 (#1636)
Bumps [intercom-rails](https://github.com/intercom/intercom-rails) from 1.0.5 to 1.0.6.
- [Release notes](https://github.com/intercom/intercom-rails/releases)
- [Commits](https://github.com/intercom/intercom-rails/commits)

---
updated-dependencies:
- dependency-name: intercom-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>
2025-01-20 09:35:58 -05:00
dependabot[bot]
a9c1e85a58 Bump tailwindcss-rails from 3.2.0 to 3.3.0 (#1635)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 3.2.0 to 3.3.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/v3.2.0...v3.3.0)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  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>
2025-01-20 09:35:49 -05:00
Zach Gollwitzer
8c8e972dc8 Bump to v0.3.0
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2025-01-17 17:01:26 -05:00
tlink
ae9287ec9b FIX: correct display of percentages (#1622)
* FIX: correct display of percentages

* FIX: correct display of percentages

* FIX: correct display of percentages
2025-01-17 11:21:00 -05:00
Jasper Delahaije
aac9e5eca2 Update family.rb (#1629)
Add where statement to account_transactions overview to only give transactions and not valuations

Signed-off-by: Jasper Delahaije <47220315+Repsay@users.noreply.github.com>
2025-01-17 09:48:16 -05:00
Zach Gollwitzer
ca8bdb6241 Fix budget money formatting (#1626) 2025-01-16 19:05:34 -05:00
Zach Gollwitzer
1ae4b4d612 Fix transfer matching logic (#1625)
* Fix transfer matching logic

* Fix tests
2025-01-16 17:56:42 -05:00
Zach Gollwitzer
60f1a1e2d2 Fix budget edit button 2025-01-16 16:24:14 -05:00
Zach Gollwitzer
e1d3c7a4a1 Add CA country code to Plaid link
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2025-01-16 16:02:06 -05:00
Zach Gollwitzer
195ec85d96 Budgeting V1 (#1609)
* Budgeting V1

* Basic UI template

* Fully scaffolded budgeting v1

* Basic working budget

* Finalize donut chart for budgets

* Allow categorization of loan payments for budget

* Include loan payments in incomes_and_expenses scope

* Add budget allocations progress

* Empty states

* Clean up budget methods

* Category aggregation queries

* Handle overage scenarios in form

* Finalize budget donut chart controller

* Passing tests

* Fix allocation naming

* Add income category migration

* Native support for uncategorized budget category

* Formatting

* Fix subcategory sort order, padding

* Fix calculation for category rollups in budget
2025-01-16 14:36:37 -05:00
dependabot[bot]
413ec6cbed Bump erb_lint from 0.7.0 to 0.8.0 (#1616)
Bumps [erb_lint](https://github.com/Shopify/erb-lint) from 0.7.0 to 0.8.0.
- [Release notes](https://github.com/Shopify/erb-lint/releases)
- [Commits](https://github.com/Shopify/erb-lint/compare/v0.7.0...v0.8.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>
2025-01-13 12:20:13 -05:00
dependabot[bot]
e4e5ae9f25 Bump ruby-lsp-rails from 0.3.27 to 0.3.29 (#1617)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.27 to 0.3.29.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.27...v0.3.29)

---
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>
2025-01-13 12:20:03 -05:00
dependabot[bot]
5449fc49ef Bump tailwindcss-rails from 3.1.0 to 3.2.0 (#1618)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 3.1.0 to 3.2.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/v3.1.0...v3.2.0)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  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>
2025-01-13 12:19:52 -05:00
dependabot[bot]
b50b7b30e8 Bump good_job from 4.6.0 to 4.7.0 (#1596)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.6.0 to 4.7.0.
- [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.6.0...v4.7.0)

---
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>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2025-01-07 11:55:46 -05:00
dependabot[bot]
871a68b5bc Bump tailwindcss-rails from 3.0.0 to 3.1.0 (#1597)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 3.0.0 to 3.1.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/v3.0.0...v3.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2025-01-07 11:55:24 -05:00
dependabot[bot]
1f4c2165eb Bump aws-sdk-s3 from 1.176.1 to 1.177.0 (#1598)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.176.1 to 1.177.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>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2025-01-07 11:55:13 -05:00
dependabot[bot]
71598d26cb Bump jwt from 2.9.3 to 2.10.1 (#1600)
Bumps [jwt](https://github.com/jwt/ruby-jwt) from 2.9.3 to 2.10.1.
- [Release notes](https://github.com/jwt/ruby-jwt/releases)
- [Changelog](https://github.com/jwt/ruby-jwt/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jwt/ruby-jwt/compare/v2.9.3...v2.10.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2025-01-07 11:55:02 -05:00
Zach Gollwitzer
997d0355d4 Use livereload from source 2025-01-07 11:54:19 -05:00
Zach Gollwitzer
2c30e18c9b Fix enrichment setting 2025-01-07 11:31:44 -05:00
Zach Gollwitzer
307a3687e8 Transfer and Payment auto-matching, model and UI improvements (#1585)
* Transfer data model migration

* Transfers and payment modeling and UI improvements

* Fix CI

* Transfer matching flow

* Better UI for transfers

* Auto transfer matching, approve, reject flow

* Mark transfers created from form as confirmed

* Account filtering

* Excluded rejected transfers from calculations

* Calculation tweaks with transfer exclusions

* Clean up migration
2025-01-07 09:41:24 -05:00
Tony Vincent
46e129308f Fix: breaking change after bumping hotwire-livereload to 2.0.0 (#1589)
Co-authored-by: Tony Vincent Yesudas <tony.yesudas@raisenow.com>
2025-01-03 15:36:11 -06:00
dependabot[bot]
5d1a2937bb Bump sentry-rails from 5.22.0 to 5.22.1 (#1568)
Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 5.22.0 to 5.22.1.
- [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.22.0...5.22.1)

---
updated-dependencies:
- dependency-name: sentry-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-12-30 10:16:45 -05:00
dependabot[bot]
b82b82ddf7 Bump intercom-rails from 1.0.1 to 1.0.5 (#1573)
Bumps [intercom-rails](https://github.com/intercom/intercom-rails) from 1.0.1 to 1.0.5.
- [Release notes](https://github.com/intercom/intercom-rails/releases)
- [Commits](https://github.com/intercom/intercom-rails/commits)

---
updated-dependencies:
- dependency-name: intercom-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-12-30 10:16:37 -05:00
dependabot[bot]
97852bc3b4 Bump importmap-rails from 2.0.3 to 2.1.0 (#1567)
Bumps [importmap-rails](https://github.com/rails/importmap-rails) from 2.0.3 to 2.1.0.
- [Release notes](https://github.com/rails/importmap-rails/releases)
- [Commits](https://github.com/rails/importmap-rails/compare/v2.0.3...v2.1.0)

---
updated-dependencies:
- dependency-name: importmap-rails
  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-12-30 10:06:51 -05:00
dependabot[bot]
84d2aac1a5 Bump faraday-multipart from 1.0.4 to 1.1.0 (#1566)
Bumps [faraday-multipart](https://github.com/lostisland/faraday-multipart) from 1.0.4 to 1.1.0.
- [Release notes](https://github.com/lostisland/faraday-multipart/releases)
- [Changelog](https://github.com/lostisland/faraday-multipart/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday-multipart/compare/v1.0.4...v1.1.0)

---
updated-dependencies:
- dependency-name: faraday-multipart
  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-12-30 10:06:43 -05:00
dependabot[bot]
49d3a9c7e7 Bump sentry-ruby from 5.22.0 to 5.22.1 (#1570)
Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 5.22.0 to 5.22.1.
- [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.22.0...5.22.1)

---
updated-dependencies:
- dependency-name: sentry-ruby
  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-12-30 10:06:34 -05:00
dependabot[bot]
b7019744a1 Bump stripe from 13.2.0 to 13.3.0 (#1572)
Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.2.0 to 13.3.0.
- [Release notes](https://github.com/stripe/stripe-ruby/releases)
- [Changelog](https://github.com/stripe/stripe-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-ruby/compare/v13.2.0...v13.3.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-30 10:06:27 -05:00
dependabot[bot]
a9e791f94c Bump csv from 3.3.1 to 3.3.2 (#1571)
Bumps [csv](https://github.com/ruby/csv) from 3.3.1 to 3.3.2.
- [Release notes](https://github.com/ruby/csv/releases)
- [Changelog](https://github.com/ruby/csv/blob/master/NEWS.md)
- [Commits](https://github.com/ruby/csv/compare/v3.3.1...v3.3.2)

---
updated-dependencies:
- dependency-name: csv
  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-12-30 10:06:12 -05:00
dependabot[bot]
cce373c31b Bump debug from 1.9.2 to 1.10.0 (#1569)
Bumps [debug](https://github.com/ruby/debug) from 1.9.2 to 1.10.0.
- [Release notes](https://github.com/ruby/debug/releases)
- [Commits](https://github.com/ruby/debug/compare/v1.9.2...v1.10.0)

---
updated-dependencies:
- dependency-name: debug
  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-12-30 10:06:04 -05:00
dependabot[bot]
0220861a3b Bump dotenv-rails from 3.1.6 to 3.1.7 (#1574)
Bumps [dotenv-rails](https://github.com/bkeepers/dotenv) from 3.1.6 to 3.1.7.
- [Release notes](https://github.com/bkeepers/dotenv/releases)
- [Changelog](https://github.com/bkeepers/dotenv/blob/main/Changelog.md)
- [Commits](https://github.com/bkeepers/dotenv/compare/v3.1.6...v3.1.7)

---
updated-dependencies:
- dependency-name: dotenv-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-12-30 10:05:53 -05:00
dependabot[bot]
fb6b6ce63d Bump hotwire-livereload from 1.4.1 to 2.0.0 (#1582)
Bumps [hotwire-livereload](https://github.com/kirillplatonov/hotwire-livereload) from 1.4.1 to 2.0.0.
- [Release notes](https://github.com/kirillplatonov/hotwire-livereload/releases)
- [Commits](https://github.com/kirillplatonov/hotwire-livereload/compare/v1.4.1...v2.0.0)

---
updated-dependencies:
- dependency-name: hotwire-livereload
  dependency-type: direct:development
  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-12-30 10:05:45 -05:00
Kabiru Mwenja
dba10c2bc8 Fix unknown attribute 'parent_category' for Category in demo generator (#1575)
```ruby
❯ bin/rails demo_data:reset
user reset
bin/rails aborted!
ActiveModel::UnknownAttributeError: unknown attribute 'parent_category' for Category. (ActiveModel::UnknownAttributeError)

          raise UnknownAttributeError.new(self, k.to_s)
```

Follows: https://github.com/maybe-finance/maybe/pull/1561
2024-12-30 10:04:58 -05:00
Tony Vincent
b0d9891133 fix: Bug creating duplicate category leads to crash screen (#1577)
Co-authored-by: Tony Vincent Yesudas <tony.yesudas@raisenow.com>
2024-12-30 10:04:38 -05:00
Tony Vincent
9d217afb9f feat: Save error backtrace for sync errors for better debugging (#1578)
Co-authored-by: Tony Vincent Yesudas <tony.yesudas@raisenow.com>
2024-12-30 10:04:05 -05:00
Zach Gollwitzer
77def1db40 Nested Categories (#1561)
* Prepare entry search for nested categories

* Subcategory implementation

* Remove caching for test stability
2024-12-20 11:37:26 -05:00
Zach Gollwitzer
a4d10097d5 Preserve pagination on entry updates (#1563)
* Preserve pagination on entry updates

* Test fix
2024-12-20 11:24:46 -05:00
Zach Gollwitzer
7be6a372bf Preserve original transaction names when enriching (#1556)
* Preserve original transaction name

* Remove stale method

* Fix tests
2024-12-19 10:16:09 -05:00
Zach Gollwitzer
68617514b0 Make transaction enrichment opt-in for all users (#1552) 2024-12-17 09:58:08 -05:00
dependabot[bot]
ba878c3d8b Bump rails-settings-cached from 2.9.5 to 2.9.6 (#1547)
Bumps [rails-settings-cached](https://github.com/huacnlee/rails-settings-cached) from 2.9.5 to 2.9.6.
- [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.5...v2.9.6)

---
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>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-12-16 14:00:08 -05:00
dependabot[bot]
6034dfe5f5 Bump good_job from 4.5.1 to 4.6.0 (#1541)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.5.1 to 4.6.0.
- [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.5.1...v4.6.0)

---
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>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-12-16 13:42:36 -05:00
dependabot[bot]
ae30176816 Bump aws-sdk-s3 from 1.176.0 to 1.176.1 (#1545)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.176.0 to 1.176.1.
- [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-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-12-16 13:42:27 -05:00
dependabot[bot]
7508ae55ac Bump dotenv-rails from 3.1.4 to 3.1.6 (#1540)
Bumps [dotenv-rails](https://github.com/bkeepers/dotenv) from 3.1.4 to 3.1.6.
- [Release notes](https://github.com/bkeepers/dotenv/releases)
- [Changelog](https://github.com/bkeepers/dotenv/blob/main/Changelog.md)
- [Commits](https://github.com/bkeepers/dotenv/compare/v3.1.4...v3.1.6)

---
updated-dependencies:
- dependency-name: dotenv-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>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-12-16 13:42:11 -05:00
Zach Gollwitzer
bb9fa56add Fix date format validation error (#1551)
* Fix date format validation error

* Order trades, fix flaky test
2024-12-16 13:21:30 -05:00
dependabot[bot]
54e46c1b4e Bump faraday from 2.12.1 to 2.12.2 (#1542)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.12.1 to 2.12.2.
- [Release notes](https://github.com/lostisland/faraday/releases)
- [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday/compare/v2.12.1...v2.12.2)

---
updated-dependencies:
- dependency-name: faraday
  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-12-16 13:01:05 -05:00
dependabot[bot]
0d09f2e3e9 Bump csv from 3.3.0 to 3.3.1 (#1543)
Bumps [csv](https://github.com/ruby/csv) from 3.3.0 to 3.3.1.
- [Release notes](https://github.com/ruby/csv/releases)
- [Changelog](https://github.com/ruby/csv/blob/master/NEWS.md)
- [Commits](https://github.com/ruby/csv/compare/v3.3.0...v3.3.1)

---
updated-dependencies:
- dependency-name: csv
  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-12-16 13:00:25 -05:00
dependabot[bot]
f7ce2cdf89 Bump mocha from 2.7.0 to 2.7.1 (#1544)
Bumps [mocha](https://github.com/freerange/mocha) from 2.7.0 to 2.7.1.
- [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md)
- [Commits](https://github.com/freerange/mocha/compare/v2.7.0...v2.7.1)

---
updated-dependencies:
- dependency-name: mocha
  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-12-16 13:00:08 -05:00
dependabot[bot]
f7e86d4c90 Bump rails from 7.2.2 to 7.2.2.1 (#1546)
Bumps [rails](https://github.com/rails/rails) from 7.2.2 to 7.2.2.1.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](https://github.com/rails/rails/compare/v7.2.2...v7.2.2.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-12-16 12:52:29 -05:00
Zach Gollwitzer
45add7512b Handle nil name for entries (#1550)
* Handle nil name for entries

* Fix tests
2024-12-16 12:52:11 -05:00
Zach Gollwitzer
9130089950 Make data enrichment opt-in 2024-12-16 10:37:59 -05:00
Zach Gollwitzer
fe199f2357 Add account data enrichment (#1532)
* Add data enrichment

* Make data enrichment optional for self-hosters

* Add categories to data enrichment

* Only update category and merchant if nil

* Fix name overrides

* Lint fixes
2024-12-13 17:22:27 -05:00
282 changed files with 5407 additions and 1757 deletions

View File

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

View File

@@ -118,4 +118,6 @@ STRIPE_WEBHOOK_SECRET=
#
PLAID_CLIENT_ID=
PLAID_SECRET=
PLAID_ENV=
PLAID_ENV=
PLAID_EU_CLIENT_ID=
PLAID_EU_SECRET=

View File

@@ -2,11 +2,18 @@
name: Bug report
about: Create a report to help us improve
title: 'Bug: '
labels: ":bug: Bug"
labels: ''
assignees: ''
---
**Where did this bug occur? (required)**
- [ ] I am a self-hosted user reporting a bug from my self hosted app
- [ ] I am a user of Maybe's paid app
_Please note, if you are reporting a bug with sensitive data, please open an Intercom chat from within the app for help_
**Describe the bug**
A clear and concise description of what the bug is.
@@ -20,14 +27,5 @@ Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**What version of Maybe are you using?**
This could be "Hosted" (i.e. app.maybefinance.com) or "Self-hosted". If "Self-hosted", please include the version you're currently on.
**What operating system and browser are you using?**
The more info the better.
**Screenshots / Recordings**
If applicable, add screenshots or short video recordings to help show the bug in more detail.
**Additional context**
Add any other context about the problem here.

View File

@@ -65,7 +65,7 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64,linux/arm/v7' || 'linux/amd64,linux/arm64' }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false

View File

@@ -1 +1 @@
3.3.5
3.4.1

View File

@@ -1,8 +1,8 @@
# syntax = docker/dockerfile:1
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.3.5
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
ARG RUBY_VERSION=3.4.1
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
@@ -19,7 +19,7 @@ ENV RAILS_ENV="production" \
# Throw-away build stage to reduce size of final image
FROM base as build
FROM base AS build
# Install packages needed to build gems
RUN apt-get install --no-install-recommends -y build-essential git libpq-dev pkg-config

View File

@@ -28,11 +28,12 @@ gem "good_job"
# Error logging
gem "stackprof"
gem "rack-mini-profiler"
gem "sentry-ruby"
gem "sentry-rails"
# Active Storage
gem "aws-sdk-s3", require: false
gem "aws-sdk-s3", "~> 1.177.0", require: false
gem "image_processing", ">= 1.2"
# Other
@@ -67,6 +68,7 @@ group :development do
gem "ruby-lsp-rails"
gem "web-console"
gem "faker"
gem "benchmark-ips"
end
group :test do

View File

@@ -8,29 +8,29 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.2)
actionpack (= 7.2.2)
activesupport (= 7.2.2)
actioncable (7.2.2.1)
actionpack (= 7.2.2.1)
activesupport (= 7.2.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.2)
actionpack (= 7.2.2)
activejob (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
actionmailbox (7.2.2.1)
actionpack (= 7.2.2.1)
activejob (= 7.2.2.1)
activerecord (= 7.2.2.1)
activestorage (= 7.2.2.1)
activesupport (= 7.2.2.1)
mail (>= 2.8.0)
actionmailer (7.2.2)
actionpack (= 7.2.2)
actionview (= 7.2.2)
activejob (= 7.2.2)
activesupport (= 7.2.2)
actionmailer (7.2.2.1)
actionpack (= 7.2.2.1)
actionview (= 7.2.2.1)
activejob (= 7.2.2.1)
activesupport (= 7.2.2.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.2)
actionview (= 7.2.2)
activesupport (= 7.2.2)
actionpack (7.2.2.1)
actionview (= 7.2.2.1)
activesupport (= 7.2.2.1)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.2)
@@ -39,35 +39,35 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.2)
actionpack (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
actiontext (7.2.2.1)
actionpack (= 7.2.2.1)
activerecord (= 7.2.2.1)
activestorage (= 7.2.2.1)
activesupport (= 7.2.2.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.2)
activesupport (= 7.2.2)
actionview (7.2.2.1)
activesupport (= 7.2.2.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.2)
activesupport (= 7.2.2)
activejob (7.2.2.1)
activesupport (= 7.2.2.1)
globalid (>= 0.3.6)
activemodel (7.2.2)
activesupport (= 7.2.2)
activerecord (7.2.2)
activemodel (= 7.2.2)
activesupport (= 7.2.2)
activemodel (7.2.2.1)
activesupport (= 7.2.2.1)
activerecord (7.2.2.1)
activemodel (= 7.2.2.1)
activesupport (= 7.2.2.1)
timeout (>= 0.4.0)
activestorage (7.2.2)
actionpack (= 7.2.2)
activejob (= 7.2.2)
activerecord (= 7.2.2)
activesupport (= 7.2.2)
activestorage (7.2.2.1)
actionpack (= 7.2.2.1)
activejob (= 7.2.2.1)
activerecord (= 7.2.2.1)
activesupport (= 7.2.2.1)
marcel (~> 1.0)
activesupport (7.2.2)
activesupport (7.2.2.1)
base64
benchmark (>= 0.3)
bigdecimal
@@ -83,24 +83,25 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.1018.0)
aws-sdk-core (3.214.0)
aws-partitions (1.1043.0)
aws-sdk-core (3.217.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (1.97.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.176.0)
aws-sdk-s3 (1.177.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.1)
aws-sigv4 (1.11.0)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.4.0)
benchmark-ips (2.14.0)
better_html (2.1.1)
actionview (>= 6.0)
activesupport (>= 6.0)
@@ -108,11 +109,11 @@ GEM
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (3.1.8)
bigdecimal (3.1.9)
bindex (0.8.1)
bootsnap (1.18.4)
msgpack (~> 1.2)
brakeman (6.2.2)
brakeman (7.0.0)
racc
builder (3.3.0)
capybara (3.40.0)
@@ -124,76 +125,80 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
childprocess (5.0.0)
childprocess (5.1.0)
logger (~> 1.5)
climate_control (1.2.0)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
concurrent-ruby (1.3.5)
connection_pool (2.5.0)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
csv (3.3.0)
date (3.4.0)
debug (1.9.2)
csv (3.3.2)
date (3.4.1)
debug (1.10.0)
irb (~> 1.10)
reline (>= 0.3.8)
docile (1.4.0)
dotenv (3.1.4)
dotenv-rails (3.1.4)
dotenv (= 3.1.4)
docile (1.4.1)
dotenv (3.1.7)
dotenv-rails (3.1.7)
dotenv (= 3.1.7)
railties (>= 6.1)
drb (2.2.1)
erb_lint (0.7.0)
erb_lint (0.9.0)
activesupport
better_html (>= 2.0.1)
parser (>= 2.7.1.4)
rainbow
rubocop (>= 1)
smart_properties
erubi (1.13.0)
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
faker (3.5.1)
i18n (>= 1.8.11, < 2)
faraday (2.12.1)
faraday (2.12.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-multipart (1.1.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
faraday-retry (2.2.1)
faraday (~> 2.0)
ffi (1.17.0-aarch64-linux-gnu)
ffi (1.17.0-arm-linux-gnu)
ffi (1.17.0-arm64-darwin)
ffi (1.17.0-x86-linux-gnu)
ffi (1.17.0-x86_64-darwin)
ffi (1.17.0-x86_64-linux-gnu)
ffi (1.17.1-aarch64-linux-gnu)
ffi (1.17.1-aarch64-linux-musl)
ffi (1.17.1-arm-linux-gnu)
ffi (1.17.1-arm-linux-musl)
ffi (1.17.1-arm64-darwin)
ffi (1.17.1-x86_64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
ffi (1.17.1-x86_64-linux-musl)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (4.5.1)
good_job (4.8.2)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
fugit (>= 1.11.0)
railties (>= 6.1.0)
thor (>= 1.0.0)
hashdiff (1.1.1)
highline (3.0.1)
hotwire-livereload (1.4.1)
actioncable (>= 6.0.0)
hashdiff (1.1.2)
highline (3.1.2)
reline
hotwire-livereload (2.0.0)
actioncable (>= 7.0.0)
listen (>= 3.0.0)
railties (>= 6.0.0)
railties (>= 7.0.0)
hotwire_combobox (0.3.2)
rails (>= 7.0.7.2)
stimulus-rails (>= 1.2)
turbo-rails (>= 1.2)
i18n (1.14.6)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.14)
activesupport (>= 4.0.2)
@@ -208,34 +213,37 @@ GEM
image_processing (1.13.0)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.0.3)
importmap-rails (2.1.0)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
inline_svg (1.10.0)
activesupport (>= 3.0)
nokogiri (>= 1.6)
intercom-rails (1.0.1)
intercom-rails (1.0.6)
activesupport (> 4.0)
jwt (~> 2.0)
io-console (0.8.0)
irb (1.14.1)
irb (1.15.1)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.8.2)
jwt (2.9.3)
json (2.9.1)
jwt (2.10.1)
base64
language_server-protocol (3.17.0.3)
launchy (3.0.1)
language_server-protocol (3.17.0.4)
launchy (3.1.0)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.6.2)
loofah (2.23.1)
logger (1.6.5)
loofah (2.24.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -248,13 +256,13 @@ GEM
mini_magick (4.13.2)
mini_mime (1.1.5)
minitest (5.25.4)
mocha (2.7.0)
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
msgpack (1.7.5)
multipart-post (2.4.1)
net-http (0.5.0)
net-http (0.6.0)
uri
net-imap (0.5.0)
net-imap (0.5.5)
date
net-protocol
net-pop (0.1.2)
@@ -264,81 +272,91 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.4)
nokogiri (1.17.0-aarch64-linux)
nokogiri (1.18.2-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.17.0-arm-linux)
nokogiri (1.18.2-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.17.0-arm64-darwin)
nokogiri (1.18.2-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.17.0-x86-linux)
nokogiri (1.18.2-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.17.0-x86_64-darwin)
nokogiri (1.18.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.17.0-x86_64-linux)
nokogiri (1.18.2-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.2-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.2-x86_64-linux-musl)
racc (~> 1.4)
octokit (9.2.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (9.3.3)
parallel (1.26.3)
parser (3.3.5.0)
parser (3.3.7.0)
ast (~> 2.4.1)
racc
pg (1.5.9)
plaid (34.0.0)
plaid (35.1.0)
faraday (>= 1.0.1, < 3.0)
faraday-multipart (>= 1.0.1, < 2.0)
prism (1.2.0)
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
prism (1.3.0)
propshaft (1.1.0)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
railties (>= 7.0.0)
psych (5.2.1)
psych (5.2.3)
date
stringio
public_suffix (6.0.1)
puma (6.5.0)
puma (6.6.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.8)
rack-session (2.0.0)
rack (3.1.9)
rack-mini-profiler (3.3.1)
rack (>= 1.2.0)
rack-session (2.1.0)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.1.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
rails (7.2.2)
actioncable (= 7.2.2)
actionmailbox (= 7.2.2)
actionmailer (= 7.2.2)
actionpack (= 7.2.2)
actiontext (= 7.2.2)
actionview (= 7.2.2)
activejob (= 7.2.2)
activemodel (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
rails (7.2.2.1)
actioncable (= 7.2.2.1)
actionmailbox (= 7.2.2.1)
actionmailer (= 7.2.2.1)
actionpack (= 7.2.2.1)
actiontext (= 7.2.2.1)
actionview (= 7.2.2.1)
activejob (= 7.2.2.1)
activemodel (= 7.2.2.1)
activerecord (= 7.2.2.1)
activestorage (= 7.2.2.1)
activesupport (= 7.2.2.1)
bundler (>= 1.15.0)
railties (= 7.2.2)
railties (= 7.2.2.1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.1)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.9)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
rails-settings-cached (2.9.5)
rails-settings-cached (2.9.6)
activerecord (>= 5.0.0)
railties (>= 5.0.0)
railties (7.2.2)
actionpack (= 7.2.2)
activesupport (= 7.2.2)
railties (7.2.2.1)
actionpack (= 7.2.2.1)
activesupport (= 7.2.2.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -349,104 +367,105 @@ GEM
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
rbs (3.6.1)
rbs (3.8.1)
logger
rdoc (6.8.1)
rdoc (6.11.0)
psych (>= 4.0.0)
redcarpet (3.6.0)
regexp_parser (2.9.2)
reline (0.5.12)
regexp_parser (2.10.0)
reline (0.6.0)
io-console (~> 0.5)
rexml (3.3.9)
rubocop (1.67.0)
rexml (3.4.0)
rubocop (1.71.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.4, < 3.0)
rubocop-ast (>= 1.32.2, < 2.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.36.2, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.32.3)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.38.0)
parser (>= 3.3.1.0)
rubocop-minitest (0.35.0)
rubocop-minitest (0.36.0)
rubocop (>= 1.61, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-performance (1.21.0)
rubocop-performance (1.23.1)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.25.0)
rubocop-rails (2.29.1)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails-omakase (1.0.0)
rubocop
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.22.1)
ruby-lsp (0.23.6)
language_server-protocol (~> 3.17.0)
prism (>= 1.2, < 2.0)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.3.27)
ruby-lsp (>= 0.22.0, < 0.23.0)
ruby-lsp-rails (0.3.31)
ruby-lsp (>= 0.23.0, < 0.24.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.2)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
rubyzip (2.4.1)
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
securerandom (0.4.0)
selenium-webdriver (4.27.0)
securerandom (0.4.1)
selenium-webdriver (4.28.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sentry-rails (5.22.0)
sentry-rails (5.22.3)
railties (>= 5.0)
sentry-ruby (~> 5.22.0)
sentry-ruby (5.22.0)
sentry-ruby (~> 5.22.3)
sentry-ruby (5.22.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.12.3)
simplecov-html (0.13.1)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11663)
stackprof (0.2.26)
sorbet-runtime (0.5.11781)
stackprof (0.2.27)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.2)
stripe (13.2.0)
tailwindcss-rails (3.0.0)
stripe (13.4.1)
tailwindcss-rails (3.3.1)
railties (>= 7.0.0)
tailwindcss-ruby
tailwindcss-ruby (3.4.14)
tailwindcss-ruby (3.4.14-aarch64-linux)
tailwindcss-ruby (3.4.14-arm-linux)
tailwindcss-ruby (3.4.14-arm64-darwin)
tailwindcss-ruby (3.4.14-x86_64-darwin)
tailwindcss-ruby (3.4.14-x86_64-linux)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
tailwindcss-ruby (~> 3.0)
tailwindcss-ruby (3.4.17-aarch64-linux)
tailwindcss-ruby (3.4.17-arm-linux)
tailwindcss-ruby (3.4.17-arm64-darwin)
tailwindcss-ruby (3.4.17-x86_64-darwin)
tailwindcss-ruby (3.4.17-x86_64-linux)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
thor (1.3.2)
timeout (0.4.2)
timeout (0.4.3)
turbo-rails (2.0.11)
actionpack (>= 6.0.0)
railties (>= 6.0.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.6.0)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.2)
useragent (0.16.11)
vcr (6.3.1)
@@ -461,7 +480,8 @@ GEM
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11)
websocket-driver (0.7.6)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
@@ -470,15 +490,21 @@ GEM
PLATFORMS
aarch64-linux
aarch64-linux-gnu
aarch64-linux-musl
arm-linux
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86-linux
x86_64-darwin
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
aws-sdk-s3
aws-sdk-s3 (~> 1.177.0)
bcrypt (~> 3.1)
benchmark-ips
bootsnap
brakeman
capybara
@@ -509,6 +535,7 @@ DEPENDENCIES
plaid
propshaft
puma (>= 5.0)
rack-mini-profiler
rails (~> 7.2.2)
rails-settings-cached
redcarpet
@@ -529,7 +556,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 3.3.5p100
ruby 3.4.1p0
BUNDLED WITH
2.5.22
2.6.3

View File

@@ -29,6 +29,11 @@
@apply focus:opacity-100 focus:outline-none focus:ring-0;
@apply placeholder-shown:opacity-50;
@apply disabled:text-gray-400;
@apply text-ellipsis overflow-hidden whitespace-nowrap;
}
select.form-field__input {
@apply pr-8;
}
.form-field__radio {
@@ -51,16 +56,24 @@
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
}
[type='checkbox'].maybe-checkbox--light:disabled {
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
}
[type='checkbox'].maybe-checkbox--dark {
@apply ring-gray-900 checked:text-white;
}
[type='checkbox'].maybe-checkbox--dark:disabled {
@apply cursor-not-allowed opacity-80 ring-gray-600;
}
[type='checkbox'].maybe-checkbox--dark:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
}
select[multiple="multiple"] {
@apply py-2 pr-2 space-y-0.5;
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
}
select[multiple="multiple"] option {

View File

@@ -1,26 +0,0 @@
class Account::EntriesController < ApplicationController
layout :with_sidebar
before_action :set_account
def index
@q = search_params
@pagy, @entries = pagy(entries_scope.search(@q).reverse_chronological, limit: params[:per_page] || "10")
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def entries_scope
scope = Current.family.entries
scope = scope.where(account: @account) if @account
scope
end
def search_params
params.fetch(:q, {})
.permit(:search)
end
end

View File

@@ -21,24 +21,6 @@ class Account::TransactionsController < ApplicationController
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
end
def mark_transfers
Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.mark_transfers!
redirect_back_or_to transactions_url, notice: t(".success")
end
def unmark_transfers
Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.update_all marked_as_transfer: false
redirect_back_or_to transactions_url, notice: t(".success")
end
private
def bulk_delete_params
params.require(:bulk_delete).permit(entry_ids: [])

View File

@@ -0,0 +1,56 @@
class Account::TransferMatchesController < ApplicationController
before_action :set_entry
def new
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
@transfer_match_candidates = @entry.transfer_match_candidates
end
def create
@transfer = build_transfer
@transfer.save!
@transfer.sync_account_later
redirect_back_or_to transactions_path, notice: t(".success")
end
private
def set_entry
@entry = Current.family.entries.find(params[:transaction_id])
end
def transfer_match_params
params.require(:transfer_match).permit(:method, :matched_entry_id, :target_account_id)
end
def build_transfer
if transfer_match_params[:method] == "new"
target_account = Current.family.accounts.find(transfer_match_params[:target_account_id])
missing_transaction = Account::Transaction.new(
entry: target_account.entries.build(
amount: @entry.amount * -1,
currency: @entry.currency,
date: @entry.date,
name: "Transfer to #{@entry.amount.negative? ? @entry.account.name : target_account.name}",
)
)
transfer = Transfer.find_or_initialize_by(
inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.account_transaction,
outflow_transaction: @entry.amount.positive? ? @entry.account_transaction : missing_transaction
)
transfer.status = "confirmed"
transfer
else
target_transaction = Current.family.entries.find(transfer_match_params[:matched_entry_id])
transfer = Transfer.find_or_initialize_by(
inflow_transaction: @entry.amount.negative? ? @entry.account_transaction : target_transaction.account_transaction,
outflow_transaction: @entry.amount.negative? ? target_transaction.account_transaction : @entry.account_transaction
)
transfer.status = "confirmed"
transfer
end
end
end

View File

@@ -1,61 +0,0 @@
class Account::TransfersController < ApplicationController
layout :with_sidebar
before_action :set_transfer, only: %i[destroy show update]
def new
@transfer = Account::Transfer.new
end
def show
end
def create
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
@transfer = Account::Transfer.build_from_accounts from_account, to_account, \
date: transfer_params[:date],
amount: transfer_params[:amount].to_d
if @transfer.save
@transfer.entries.each(&:sync_account_later)
redirect_to transactions_path, notice: t(".success")
else
# TODO: this is not an ideal way to handle errors and should eventually be improved.
# See: https://github.com/hotwired/turbo-rails/pull/367
flash[:alert] = @transfer.errors.full_messages.to_sentence
redirect_to transactions_path
end
end
def update
@transfer.update_entries!(transfer_update_params)
redirect_back_or_to transactions_url, notice: t(".success")
end
def destroy
@transfer.destroy!
redirect_back_or_to transactions_url, notice: t(".success")
end
private
def set_transfer
record = Account::Transfer.find(params[:id])
unless record.entries.all? { |entry| Current.family.accounts.include?(entry.account) }
raise ActiveRecord::RecordNotFound
end
@transfer = record
end
def transfer_params
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
end
def transfer_update_params
params.require(:account_transfer).permit(:excluded, :notes)
end
end

View File

@@ -4,8 +4,8 @@ class AccountsController < ApplicationController
before_action :set_account, only: %i[sync]
def index
@manual_accounts = Current.family.accounts.where(scheduled_for_deletion: false).manual.alphabetically
@plaid_items = Current.family.plaid_items.where(scheduled_for_deletion: false).ordered
@manual_accounts = Current.family.accounts.manual.alphabetically
@plaid_items = Current.family.plaid_items.ordered
end
def summary

View File

@@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base
return false unless Current.session
return false if Current.family.subscribed?
return false if subscription_pending? || request.path == settings_billing_path
return false if Current.family.active_accounts_count <= 3
true
end

View File

@@ -0,0 +1,35 @@
class BudgetCategoriesController < ApplicationController
def index
@budget = Current.family.budgets.find(params[:budget_id])
render layout: "wizard"
end
def show
@budget = Current.family.budgets.find(params[:budget_id])
@recent_transactions = @budget.entries
if params[:id] == BudgetCategory.uncategorized.id
@budget_category = @budget.uncategorized_budget_category
@recent_transactions = @recent_transactions.where(account_transactions: { category_id: nil })
else
@budget_category = Current.family.budget_categories.find(params[:id])
@recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
.where("categories.id = ? OR categories.parent_id = ?", @budget_category.category.id, @budget_category.category.id)
end
@recent_transactions = @recent_transactions.order("account_entries.date DESC, ABS(account_entries.amount) DESC").take(3)
end
def update
@budget_category = Current.family.budget_categories.find(params[:id])
@budget_category.update!(budget_category_params)
redirect_to budget_budget_categories_path(@budget_category.budget)
end
private
def budget_category_params
params.require(:budget_category).permit(:budgeted_spending)
end
end

View File

@@ -0,0 +1,55 @@
class BudgetsController < ApplicationController
before_action :set_budget, only: %i[show edit update]
def index
redirect_to_current_month_budget
end
def show
@next_budget = @budget.next_budget
@previous_budget = @budget.previous_budget
@latest_budget = Budget.find_or_bootstrap(Current.family)
render layout: with_sidebar
end
def edit
render layout: "wizard"
end
def update
@budget.update!(budget_params)
redirect_to budget_budget_categories_path(@budget)
end
def create
start_date = Date.parse(budget_create_params[:start_date])
@budget = Budget.find_or_bootstrap(Current.family, date: start_date)
redirect_to budget_path(@budget)
end
def picker
render partial: "budgets/picker", locals: {
family: Current.family,
year: params[:year].to_i || Date.current.year
}
end
private
def budget_create_params
params.require(:budget).permit(:start_date)
end
def budget_params
params.require(:budget).permit(:budgeted_spending, :expected_income)
end
def set_budget
@budget = Current.family.budgets.find(params[:id])
@budget.sync_budget_categories
end
def redirect_to_current_month_budget
current_budget = Budget.find_or_bootstrap(Current.family)
redirect_to budget_path(current_budget)
end
end

View File

@@ -2,6 +2,7 @@ class CategoriesController < ApplicationController
layout :with_sidebar
before_action :set_category, only: %i[edit update destroy]
before_action :set_categories, only: %i[update edit]
before_action :set_transaction, only: :create
def index
@@ -10,6 +11,7 @@ class CategoriesController < ApplicationController
def new
@category = Current.family.categories.new color: Category::COLORS.sample
set_categories
end
def create
@@ -17,9 +19,17 @@ class CategoriesController < ApplicationController
if @category.save
@transaction.update(category_id: @category.id) if @transaction
redirect_back_or_to transactions_path, notice: t(".success")
flash[:notice] = t(".success")
redirect_target_url = request.referer || categories_path
respond_to do |format|
format.html { redirect_back_or_to categories_path, notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
end
else
redirect_back_or_to transactions_path, alert: t(".failure", error: @category.errors.full_messages.to_sentence)
set_categories
render :new, status: :unprocessable_entity
end
end
@@ -27,9 +37,17 @@ class CategoriesController < ApplicationController
end
def update
@category.update! category_params
if @category.update(category_params)
flash[:notice] = t(".success")
redirect_back_or_to transactions_path, notice: t(".success")
redirect_target_url = request.referer || categories_path
respond_to do |format|
format.html { redirect_back_or_to categories_path, notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
end
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@@ -38,11 +56,25 @@ class CategoriesController < ApplicationController
redirect_back_or_to categories_path, notice: t(".success")
end
def bootstrap
Current.family.categories.bootstrap_defaults
redirect_back_or_to categories_path, notice: t(".success")
end
private
def set_category
@category = Current.family.categories.find(params[:id])
end
def set_categories
@categories = unless @category.parent?
Current.family.categories.alphabetically.roots.where.not(id: @category.id)
else
[]
end
end
def set_transaction
if params[:transaction_id].present?
@transaction = Current.family.transactions.find(params[:transaction_id])
@@ -50,6 +82,6 @@ class CategoriesController < ApplicationController
end
def category_params
params.require(:category).permit(:name, :color)
params.require(:category).permit(:name, :color, :parent_id, :classification, :lucide_icon)
end
end

View File

@@ -2,6 +2,8 @@ module AccountableResource
extend ActiveSupport::Concern
included do
include ScrollFocusable
layout :with_sidebar
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
before_action :set_link_token, only: :new
@@ -22,6 +24,12 @@ module AccountableResource
end
def show
@q = params.fetch(:q, {}).permit(:search)
entries = @account.entries.search(@q).reverse_chronological
set_focused_record(entries, params[:focused_record_id])
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10", params: ->(params) { params.except(:focused_record_id) })
end
def edit
@@ -44,11 +52,21 @@ module AccountableResource
private
def set_link_token
@link_token = Current.family.get_link_token(
@us_link_token = Current.family.get_link_token(
webhooks_url: webhooks_url,
redirect_url: accounts_url,
accountable_type: accountable_type.name
accountable_type: accountable_type.name,
region: :us
)
if Current.family.eu?
@eu_link_token = Current.family.get_link_token(
webhooks_url: webhooks_url,
redirect_url: accounts_url,
accountable_type: accountable_type.name,
region: :eu
)
end
end
def webhooks_url

View File

@@ -52,11 +52,14 @@ module EntryableResource
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") }
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"header_account_entry_#{@entry.id}",
partial: "#{entryable_type.name.underscore.pluralize}/header",
locals: { entry: @entry }
)
render turbo_stream: [
turbo_stream.replace(
"header_account_entry_#{@entry.id}",
partial: "#{entryable_type.name.underscore.pluralize}/header",
locals: { entry: @entry }
),
turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry })
]
end
end
else
@@ -119,7 +122,7 @@ module EntryableResource
def entry_params
params.require(:account_entry).permit(
:account_id, :name, :date, :amount, :currency, :excluded, :notes, :nature,
:account_id, :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature,
entryable_attributes: self.class.permitted_entryable_attributes
)
end

View File

@@ -0,0 +1,21 @@
module ScrollFocusable
extend ActiveSupport::Concern
def set_focused_record(record_scope, record_id, default_per_page: 10)
return unless record_id.present?
@focused_record = record_scope.find_by(id: record_id)
record_index = record_scope.pluck(:id).index(record_id)
return unless record_index
page_of_focused_record = (record_index / (params[:per_page]&.to_i || default_per_page)) + 1
if params[:page]&.to_i != page_of_focused_record
(
redirect_to(url_for(page: page_of_focused_record, focused_record_id: record_id))
)
end
end
end

View File

@@ -0,0 +1,18 @@
class EmailConfirmationsController < ApplicationController
skip_before_action :set_request_details, only: :new
skip_authentication only: :new
def new
# Returns nil if the token is invalid OR expired
@user = User.find_by_token_for(:email_confirmation, params[:token])
if @user&.unconfirmed_email && @user&.update(
email: @user.unconfirmed_email,
unconfirmed_email: nil
)
redirect_to new_session_path, notice: t(".success_login")
else
redirect_to root_path, alert: t(".invalid_token")
end
end
end

View File

@@ -34,6 +34,24 @@ class InvitationsController < ApplicationController
end
end
def destroy
unless Current.user.admin?
flash[:alert] = t("invitations.destroy.not_authorized")
redirect_to settings_profile_path
return
end
@invitation = Current.family.invitations.find(params[:id])
if @invitation.destroy
flash[:notice] = t("invitations.destroy.success")
else
flash[:alert] = t("invitations.destroy.failure")
end
redirect_to settings_profile_path
end
private
def invitation_params

View File

@@ -6,6 +6,7 @@ class InviteCodesController < ApplicationController
end
def create
raise StandardError, "You are not allowed to generate invite codes" unless Current.user.admin?
InviteCode.generate!
redirect_back_or_to invite_codes_path, notice: "Code generated"
end

View File

@@ -21,7 +21,7 @@ class PagesController < ApplicationController
@accounts = Current.family.accounts.active
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
@transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological
@transaction_entries = Current.family.entries.incomes_and_expenses.limit(6).reverse_chronological
# TODO: Placeholders for trendlines
placeholder_series_data = 10.times.map do |i|

View File

@@ -5,6 +5,7 @@ class PlaidItemsController < ApplicationController
Current.family.plaid_items.create_from_public_token(
plaid_item_params[:public_token],
item_name: item_name,
region: plaid_item_params[:region]
)
redirect_to accounts_path, notice: t(".success")
@@ -29,7 +30,7 @@ class PlaidItemsController < ApplicationController
end
def plaid_item_params
params.require(:plaid_item).permit(:public_token, metadata: {})
params.require(:plaid_item).permit(:public_token, :region, metadata: {})
end
def item_name

View File

@@ -24,7 +24,6 @@ class RegistrationsController < ApplicationController
if @user.save
@invitation&.update!(accepted_at: Time.current)
Category.create_default_categories(@user.family) unless @invitation
@session = create_session_for(@user)
redirect_to root_path, notice: t(".success")
else

View File

@@ -5,14 +5,7 @@ class SecuritiesController < ApplicationController
@securities = Security.search({
search: query,
country: country_code_filter
country: params[:country_code] == "US" ? "US" : nil
})
end
private
def country_code_filter
filter = params[:country_code]
filter = "#{filter},US" unless filter == "US"
filter
end
end

View File

@@ -22,6 +22,10 @@ class Settings::HostingsController < SettingsController
Setting.require_invite_for_signup = hosting_params[:require_invite_for_signup]
end
if hosting_params.key?(:require_email_confirmation)
Setting.require_email_confirmation = hosting_params[:require_email_confirmation]
end
if hosting_params.key?(:synth_api_key)
Setting.synth_api_key = hosting_params[:synth_api_key]
end
@@ -34,7 +38,7 @@ class Settings::HostingsController < SettingsController
private
def hosting_params
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :synth_api_key)
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key)
end
def raise_if_not_self_hosted

View File

@@ -4,4 +4,28 @@ class Settings::ProfilesController < SettingsController
@users = Current.family.users.order(:created_at)
@pending_invitations = Current.family.invitations.pending
end
def destroy
unless Current.user.admin?
flash[:alert] = t("settings.profiles.destroy.not_authorized")
redirect_to settings_profile_path
return
end
@user = Current.family.users.find(params[:user_id])
if @user == Current.user
flash[:alert] = t("settings.profiles.destroy.cannot_remove_self")
redirect_to settings_profile_path
return
end
if @user.destroy
flash[:notice] = t("settings.profiles.destroy.member_removed")
else
flash[:alert] = t("settings.profiles.destroy.member_removal_failed")
end
redirect_to settings_profile_path
end
end

View File

@@ -1,25 +1,110 @@
class TransactionsController < ApplicationController
include ScrollFocusable
layout :with_sidebar
before_action :store_params!, only: :index
def index
@q = search_params
result = Current.family.entries.account_transactions.search(@q).reverse_chronological
@pagy, @transaction_entries = pagy(result, limit: params[:per_page] || "50")
search_query = Current.family.transactions.search(@q)
@totals = {
count: result.select { |t| t.currency == Current.family.currency }.count,
income: result.income_total(Current.family.currency).abs,
expense: result.expense_total(Current.family.currency)
set_focused_record(search_query, params[:focused_record_id], default_per_page: 50)
@pagy, @transaction_entries = pagy(
search_query.reverse_chronological.preload(
:account,
entryable: [
:category, :merchant, :tags,
:transfer_as_inflow,
transfer_as_outflow: {
inflow_transaction: { entry: :account },
outflow_transaction: { entry: :account }
}
]
),
limit: params[:per_page].presence || default_params[:per_page],
params: ->(params) { params.except(:focused_record_id) }
)
@transfers = @transaction_entries.map { |entry| entry.entryable.transfer_as_outflow }.compact
@totals = search_query.stats(Current.family.currency)
end
def clear_filter
updated_params = {
"q" => search_params,
"page" => params[:page],
"per_page" => params[:per_page]
}
q_params = updated_params["q"] || {}
param_key = params[:param_key]
param_value = params[:param_value]
if q_params[param_key].is_a?(Array)
q_params[param_key].delete(param_value)
q_params.delete(param_key) if q_params[param_key].empty?
else
q_params.delete(param_key)
end
updated_params["q"] = q_params.presence
Current.session.update!(prev_transaction_page_params: updated_params)
redirect_to transactions_path(updated_params)
end
private
def search_params
params.fetch(:q, {})
cleaned_params = params.fetch(:q, {})
.permit(
:start_date, :end_date, :search, :amount,
:amount_operator, accounts: [], account_ids: [],
categories: [], merchants: [], types: [], tags: []
)
.to_h
.compact_blank
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
cleaned_params
end
def store_params!
if should_restore_params?
params_to_restore = {}
params_to_restore[:q] = stored_params["q"].presence || default_params[:q]
params_to_restore[:page] = stored_params["page"].presence || default_params[:page]
params_to_restore[:per_page] = stored_params["per_page"].presence || default_params[:per_page]
redirect_to transactions_path(params_to_restore)
else
Current.session.update!(
prev_transaction_page_params: {
q: search_params,
page: params[:page],
per_page: params[:per_page]
}
)
end
end
def should_restore_params?
request.query_parameters.blank? && (stored_params["q"].present? || stored_params["page"].present? || stored_params["per_page"].present?)
end
def stored_params
Current.session.prev_transaction_page_params
end
def default_params
{
q: {},
page: 1,
per_page: 50
}
end
end

View File

@@ -0,0 +1,74 @@
class TransfersController < ApplicationController
layout :with_sidebar
before_action :set_transfer, only: %i[destroy show update]
def new
@transfer = Transfer.new
end
def show
@categories = Current.family.categories.expenses
end
def create
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
@transfer = Transfer.from_accounts(
from_account: from_account,
to_account: to_account,
date: transfer_params[:date],
amount: transfer_params[:amount].to_d
)
if @transfer.save
@transfer.sync_account_later
flash[:notice] = t(".success")
respond_to do |format|
format.html { redirect_back_or_to transactions_path }
redirect_target_url = request.referer || transactions_path
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
end
else
render :new, status: :unprocessable_entity
end
end
def update
if transfer_update_params[:status] == "rejected"
@transfer.reject!
elsif transfer_update_params[:status] == "confirmed"
@transfer.confirm!
end
@transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id])
respond_to do |format|
format.html { redirect_back_or_to transactions_url, notice: t(".success") }
format.turbo_stream
end
end
def destroy
@transfer.destroy!
redirect_back_or_to transactions_url, notice: t(".success")
end
private
def set_transfer
@transfer = Transfer.find(params[:id])
raise ActiveRecord::RecordNotFound unless @transfer.belongs_to_family?(Current.family)
end
def transfer_params
params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
end
def transfer_update_params
params.require(:transfer).permit(:notes, :status, :category_id)
end
end

View File

@@ -4,10 +4,23 @@ class UsersController < ApplicationController
def update
@user = Current.user
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
@user.profile_image.purge if should_purge_profile_image?
if email_changed?
if @user.initiate_email_change(user_params[:email])
if Rails.application.config.app_mode.self_hosted? && !Setting.require_email_confirmation
handle_redirect(t(".success"))
else
redirect_to settings_profile_path, notice: t(".email_change_initiated")
end
else
error_message = @user.errors.any? ? @user.errors.full_messages.to_sentence : t(".email_change_failed")
redirect_to settings_profile_path, alert: error_message
end
else
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
@user.profile_image.purge if should_purge_profile_image?
handle_redirect(t(".success"))
handle_redirect(t(".success"))
end
end
def destroy
@@ -38,10 +51,14 @@ class UsersController < ApplicationController
user_params[:profile_image].blank?
end
def email_changed?
user_params[:email].present? && user_params[:email] != @user.email
end
def user_params
params.require(:user).permit(
:first_name, :last_name, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ]
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ]
)
end

View File

@@ -3,23 +3,30 @@ module Account::EntriesHelper
"account/entries/entryables/#{permitted_entryable_key(entry)}/#{relative_partial_path}"
end
def unconfirmed_transfer?(entry)
entry.marked_as_transfer? && entry.transfer.nil?
end
def transfer_entries(entries)
transfers = entries.select { |e| e.transfer_id.present? }
transfers.map(&:transfer).uniq
end
def entries_by_date(entries, selectable: true, totals: false)
entries.reverse_chronological.group_by(&:date).map do |date, grouped_entries|
def entries_by_date(entries, transfers: [], selectable: true, totals: false)
entries.group_by(&:date).map do |date, grouped_entries|
content = capture do
yield grouped_entries
yield [ grouped_entries, transfers.select { |t| t.outflow_transaction.entry.date == date } ]
end
next if content.blank?
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable:, totals: }
end.join.html_safe
end.compact.join.html_safe
end
def entry_name_detailed(entry)
[
entry.date,
format_money(entry.amount_money),
entry.account.name,
entry.display_name
].join("")
end
private

View File

@@ -1,2 +0,0 @@
module Account::TransfersHelper
end

View File

@@ -8,6 +8,18 @@ module AccountsHelper
days_apart = (end_date - start_date).to_i
# Handle specific cases
if start_date == Date.current.beginning_of_week && end_date == Date.current
return "Current Week to Date (CWD)"
elsif start_date == Date.current.beginning_of_month && end_date == Date.current
return "Current Month to Date (MTD)"
elsif start_date == Date.current.beginning_of_quarter && end_date == Date.current
return "Current Quarter to Date (CQD)"
elsif start_date == Date.current.beginning_of_year && end_date == Date.current
return "Current Year to Date (YTD)"
end
# Default cases
case days_apart
when 1
"vs. yesterday"
@@ -15,6 +27,8 @@ module AccountsHelper
"vs. last week"
when 30, 31
"vs. last month"
when 90
"vs. last 3 months"
when 365, 366
"vs. last year"
else

View File

@@ -1,18 +1,14 @@
module ApplicationHelper
include Pagy::Frontend
def date_format_options
[
[ "DD-MM-YYYY", "%d-%m-%Y" ],
[ "DD.MM.YY", "%d.%m.%Y" ],
[ "MM-DD-YYYY", "%m-%d-%Y" ],
[ "YYYY-MM-DD", "%Y-%m-%d" ],
[ "DD/MM/YYYY", "%d/%m/%Y" ],
[ "YYYY/MM/DD", "%Y/%m/%d" ],
[ "MM/DD/YYYY", "%m/%d/%Y" ],
[ "D/MM/YYYY", "%e/%m/%Y" ],
[ "YYYY.MM.DD", "%Y.%m.%d" ]
]
def icon(key, size: "md", color: "current")
render partial: "shared/icon", locals: { key:, size:, color: }
end
# Convert alpha (0-1) to 8-digit hex (00-FF)
def hex_with_alpha(hex, alpha)
alpha_hex = (alpha * 255).round.to_s(16).rjust(2, "0")
"#{hex}#{alpha_hex}"
end
def title(page_title)
@@ -67,9 +63,9 @@ module ApplicationHelper
render partial: "shared/drawer", locals: { content:, reload_on_close: }
end
def disclosure(title, &block)
def disclosure(title, default_open: true, &block)
content = capture &block
render partial: "shared/disclosure", locals: { title: title, content: content }
render partial: "shared/disclosure", locals: { title: title, content: content, open: default_open }
end
def sidebar_link_to(name, path, options = {})

View File

@@ -1,11 +1,25 @@
module CategoriesHelper
def null_category
def transfer_category
Category.new \
name: "Uncategorized",
color: Category::UNCATEGORIZED_COLOR
name: "Transfer",
color: Category::TRANSFER_COLOR,
lucide_icon: "arrow-right-left"
end
def payment_category
Category.new \
name: "Payment",
color: Category::PAYMENT_COLOR,
lucide_icon: "arrow-right"
end
def trade_category
Category.new \
name: "Trade",
color: Category::TRADE_COLOR
end
def family_categories
[ null_category ].concat(Current.family.categories.alphabetically)
[ Category.uncategorized ].concat(Current.family.categories.alphabetically)
end
end

View File

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

View File

@@ -18,9 +18,20 @@ module FormsHelper
end
def period_select(form:, selected:, classes: "border border-alpha-black-100 shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-gray-900 focus:outline-none focus:ring-0")
periods_for_select = [ [ "7D", "last_7_days" ], [ "1M", "last_30_days" ], [ "1Y", "last_365_days" ] ]
periods_for_select = [
%w[CWD current_week], # Current Week to Date
%w[7D last_7_days],
%w[MTD current_month], # Month to Date
%w[1M last_30_days],
%w[CQD current_quarter], # Quarter to Date
%w[3M last_90_days],
%w[YTD current_year], # Year to Date
%w[1Y last_365_days]
]
form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" })
end
end
def currencies_for_select
Money::Currency.all_instances.sort_by { |currency| [ currency.priority, currency.name ] }

View File

@@ -365,6 +365,11 @@ module LanguagesHelper
end
def timezone_options
ActiveSupport::TimeZone.all.map { |tz| [ tz.name + " (#{tz.tzinfo.identifier})", tz.tzinfo.identifier ] }
ActiveSupport::TimeZone.all
.sort_by { |tz| [ tz.utc_offset, tz.name ] }
.map do |tz|
name = tz.name.split(" - ").first.gsub(" (US & Canada)", "")
[ "(#{tz.formatted_offset}) #{name}", tz.tzinfo.identifier ]
end
end
end

View File

@@ -18,21 +18,4 @@ module TransactionsHelper
def get_default_transaction_search_filter
transaction_search_filters[0]
end
def transactions_path_without_param(param_key, param_value)
updated_params = request.query_parameters.deep_dup
q_params = updated_params[:q] || {}
current_value = q_params[param_key]
if current_value.is_a?(Array)
q_params[param_key] = current_value - [ param_value ]
else
q_params.delete(param_key)
end
updated_params[:q] = q_params
transactions_path(updated_params)
end
end

View File

@@ -0,0 +1,25 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="budget-form"
export default class extends Controller {
toggleAutoFill(e) {
const expectedIncome = e.params.income;
const budgetedSpending = e.params.spending;
if (e.target.checked) {
this.#fillField(expectedIncome.key, expectedIncome.value);
this.#fillField(budgetedSpending.key, budgetedSpending.value);
} else {
this.#clearField(expectedIncome.key);
this.#clearField(budgetedSpending.key);
}
}
#fillField(id, value) {
this.element.querySelector(`input[id="${id}"]`).value = value;
}
#clearField(id) {
this.element.querySelector(`input[id="${id}"]`).value = "";
}
}

View File

@@ -99,7 +99,9 @@ export default class extends Controller {
}
_rowsForGroup(group) {
return this.rowTargets.filter((row) => group.contains(row));
return this.rowTargets.filter(
(row) => group.contains(row) && !row.disabled,
);
}
_addToSelection(idToAdd) {
@@ -115,7 +117,9 @@ export default class extends Controller {
}
_selectAll() {
this.selectedIdsValue = this.rowTargets.map((t) => t.dataset.id);
this.selectedIdsValue = this.rowTargets
.filter((t) => !t.disabled)
.map((t) => t.dataset.id);
}
_updateView = () => {

View File

@@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="color-avatar"
// Used by the transaction merchant form to show a preview of what the avatar will look like
export default class extends Controller {
static targets = ["name", "avatar"];
static targets = ["name", "avatar", "selection"];
connect() {
this.nameTarget.addEventListener("input", this.handleNameChange);
@@ -25,4 +25,10 @@ export default class extends Controller {
this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`;
this.avatarTarget.style.color = color;
}
handleParentChange(e) {
const parent = e.currentTarget.value;
const visibility = typeof parent === "string" && parent !== "" ? "hidden" : "visible"
this.selectionTarget.style.visibility = visibility
}
}

View File

@@ -0,0 +1,168 @@
import { Controller } from "@hotwired/stimulus";
import * as d3 from "d3";
// Connects to data-controller="donut-chart"
export default class extends Controller {
static targets = ["chartContainer", "contentContainer", "defaultContent"];
static values = {
segments: { type: Array, default: [] },
unusedSegmentId: { type: String, default: "unused" },
overageSegmentId: { type: String, default: "overage" },
segmentHeight: { type: Number, default: 3 },
segmentOpacity: { type: Number, default: 1 },
};
#viewBoxSize = 100;
#minSegmentAngle = this.segmentHeightValue * 0.01;
connect() {
this.#draw();
document.addEventListener("turbo:load", this.#redraw);
this.element.addEventListener("mouseleave", this.#clearSegmentHover);
}
disconnect() {
this.#teardown();
document.removeEventListener("turbo:load", this.#redraw);
this.element.removeEventListener("mouseleave", this.#clearSegmentHover);
}
get #data() {
const totalPieValue = this.segmentsValue.reduce(
(acc, s) => acc + Number(s.amount),
0,
);
// Overage is always first segment, unused is always last segment
return this.segmentsValue
.filter((s) => s.amount > 0)
.map((s) => ({
...s,
amount: Math.max(
Number(s.amount),
totalPieValue * this.#minSegmentAngle,
),
}))
.sort((a, b) => {
if (a.id === this.overageSegmentIdValue) return -1;
if (b.id === this.overageSegmentIdValue) return 1;
if (a.id === this.unusedSegmentIdValue) return 1;
if (b.id === this.unusedSegmentIdValue) return -1;
return b.amount - a.amount;
});
}
#redraw = () => {
this.#teardown();
this.#draw();
};
#teardown() {
d3.select(this.chartContainerTarget).selectAll("*").remove();
}
#draw() {
const svg = d3
.select(this.chartContainerTarget)
.append("svg")
.attr("viewBox", `0 0 ${this.#viewBoxSize} ${this.#viewBoxSize}`) // Square aspect ratio
.attr("preserveAspectRatio", "xMidYMid meet")
.attr("class", "w-full h-full");
const pie = d3
.pie()
.sortValues(null) // Preserve order of segments
.value((d) => d.amount);
const mainArc = d3
.arc()
.innerRadius(this.#viewBoxSize / 2 - this.segmentHeightValue)
.outerRadius(this.#viewBoxSize / 2)
.cornerRadius(this.segmentHeightValue)
.padAngle(this.#minSegmentAngle);
const segmentArcs = svg
.append("g")
.attr(
"transform",
`translate(${this.#viewBoxSize / 2}, ${this.#viewBoxSize / 2})`,
)
.selectAll("arc")
.data(pie(this.#data))
.enter()
.append("g")
.attr("class", "arc pointer-events-auto")
.append("path")
.attr("data-segment-id", (d) => d.data.id)
.attr("data-original-color", this.#transformRingColor)
.attr("fill", this.#transformRingColor)
.attr("d", mainArc);
// Ensures that user can click on default content without triggering hover on a segment if that is their intent
let hoverTimeout = null;
segmentArcs
.on("mouseover", (event) => {
hoverTimeout = setTimeout(() => {
this.#clearSegmentHover();
this.#handleSegmentHover(event);
}, 150);
})
.on("mouseleave", () => {
clearTimeout(hoverTimeout);
});
}
#transformRingColor = ({ data: { id, color } }) => {
if (id === this.unusedSegmentIdValue || id === this.overageSegmentIdValue) {
return color;
}
const reducedOpacityColor = d3.color(color);
reducedOpacityColor.opacity = this.segmentOpacityValue;
return reducedOpacityColor;
};
// Highlights segment and shows segment specific content (all other segments are grayed out)
#handleSegmentHover(event) {
const segmentId = event.target.dataset.segmentId;
const template = this.element.querySelector(`#segment_${segmentId}`);
const unusedSegmentId = this.unusedSegmentIdValue;
if (!template) return;
d3.select(this.chartContainerTarget)
.selectAll("path")
.attr("fill", function () {
if (this.dataset.segmentId === segmentId) {
if (this.dataset.segmentId === unusedSegmentId) {
return "#A3A3A3";
}
return this.dataset.originalColor;
}
return "#F0F0F0";
});
this.defaultContentTarget.classList.add("hidden");
template.classList.remove("hidden");
}
// Restores original segment colors and hides segment specific content
#clearSegmentHover = () => {
this.defaultContentTarget.classList.remove("hidden");
d3.select(this.chartContainerTarget)
.selectAll("path")
.attr("fill", function () {
return this.dataset.originalColor;
});
for (const child of this.contentContainerTarget.children) {
if (child !== this.defaultContentTarget) {
child.classList.add("hidden");
}
}
};
}

View File

@@ -0,0 +1,21 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="focus-record"
export default class extends Controller {
static values = {
id: String,
};
connect() {
const element = document.getElementById(this.idValue);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
// Remove the focused_record_id parameter from URL
const url = new URL(window.location);
url.searchParams.delete("focused_record_id");
window.history.replaceState({}, "", url);
}
}
}

View File

@@ -4,6 +4,7 @@ import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static values = {
linkToken: String,
region: { type: String, default: "us" },
};
open() {
@@ -18,7 +19,7 @@ export default class extends Controller {
handler.open();
}
handleSuccess(public_token, metadata) {
handleSuccess = (public_token, metadata) => {
window.location.href = "/accounts";
fetch("/plaid_items", {
@@ -31,6 +32,7 @@ export default class extends Controller {
plaid_item: {
public_token: public_token,
metadata: metadata,
region: this.regionValue,
},
}),
}).then((response) => {
@@ -38,17 +40,17 @@ export default class extends Controller {
window.location.href = response.url;
}
});
}
};
handleExit(err, metadata) {
handleExit = (err, metadata) => {
// no-op
}
};
handleEvent(eventName, metadata) {
handleEvent = (eventName, metadata) => {
// no-op
}
};
handleLoad() {
handleLoad = () => {
// no-op
}
};
}

View File

@@ -0,0 +1,20 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="selectable-link"
export default class extends Controller {
connect() {
this.element.addEventListener("change", this.handleChange.bind(this));
}
disconnect() {
this.element.removeEventListener("change", this.handleChange.bind(this));
}
handleChange(event) {
const paramName = this.element.name;
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set(paramName, event.target.value);
Turbo.visit(currentUrl.toString());
}
}

View File

@@ -0,0 +1,16 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="transfer-match"
export default class extends Controller {
static targets = ["newSelect", "existingSelect"];
update(event) {
if (event.target.value === "new") {
this.newSelectTarget.classList.remove("hidden");
this.existingSelectTarget.classList.add("hidden");
} else {
this.newSelectTarget.classList.add("hidden");
this.existingSelectTarget.classList.remove("hidden");
}
}
}

View File

@@ -1,5 +1,5 @@
class AutoUpgradeJob < ApplicationJob
queue_as :default
queue_as :latency_low
def perform(*args)
raise_if_disabled

View File

@@ -1,5 +1,5 @@
class DestroyJob < ApplicationJob
queue_as :default
queue_as :latency_low
def perform(model)
model.destroy

View File

@@ -0,0 +1,7 @@
class EnrichDataJob < ApplicationJob
queue_as :latency_high
def perform(account)
account.enrich_data
end
end

View File

@@ -1,5 +1,5 @@
class FetchSecurityInfoJob < ApplicationJob
queue_as :default
queue_as :latency_low
def perform(security_id)
return unless Security.security_info_provider.present?

View File

@@ -1,5 +1,5 @@
class ImportJob < ApplicationJob
queue_as :default
queue_as :latency_medium
def perform(import)
import.publish

View File

@@ -1,5 +1,5 @@
class SyncJob < ApplicationJob
queue_as :default
queue_as :latency_medium
def perform(sync)
sync.perform

View File

@@ -1,5 +1,5 @@
class UserPurgeJob < ApplicationJob
queue_as :default
queue_as :latency_low
def perform(user)
user.purge

View File

@@ -0,0 +1,15 @@
class EmailConfirmationMailer < ApplicationMailer
# Subject can be set in your I18n file at config/locales/en.yml
# with the following lookup:
#
# en.email_confirmation_mailer.confirmation_email.subject
#
def confirmation_email
@user = params[:user]
@subject = t(".subject")
@cta = t(".cta")
@confirmation_url = new_email_confirmation_url(token: @user.generate_token_for(:email_confirmation))
mail to: @user.unconfirmed_email, subject: @subject
end
end

View File

@@ -126,10 +126,20 @@ class Account < ApplicationRecord
classification == "asset" ? "up" : "down"
end
def enrich_data
DataEnricher.new(self).run
end
def enrich_data_later
EnrichDataJob.perform_later(self)
end
def update_with_sync!(attributes)
should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance
transaction do
update!(attributes)
update_balance!(attributes[:balance]) if attributes[:balance]
update_balance!(attributes[:balance]) if should_update_balance
end
sync_later
@@ -143,9 +153,68 @@ class Account < ApplicationRecord
else
entries.create! \
date: Date.current,
name: "Balance update",
amount: balance,
currency: currency,
entryable: Account::Valuation.new
end
end
def transfer_match_candidates
Account::Entry.select([
"inflow_candidates.entryable_id as inflow_transaction_id",
"outflow_candidates.entryable_id as outflow_transaction_id",
"ABS(inflow_candidates.date - outflow_candidates.date) as date_diff"
]).from("account_entries inflow_candidates")
.joins("
JOIN account_entries outflow_candidates ON (
inflow_candidates.amount < 0 AND
outflow_candidates.amount > 0 AND
inflow_candidates.amount = -outflow_candidates.amount AND
inflow_candidates.currency = outflow_candidates.currency AND
inflow_candidates.account_id <> outflow_candidates.account_id AND
inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4
)
").joins("
LEFT JOIN transfers existing_transfers ON (
existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id OR
existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id
)
")
.joins("LEFT JOIN rejected_transfers ON (
rejected_transfers.inflow_transaction_id = inflow_candidates.entryable_id AND
rejected_transfers.outflow_transaction_id = outflow_candidates.entryable_id
)")
.joins("JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id")
.joins("JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id")
.where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.family_id, self.family_id)
.where("inflow_candidates.entryable_type = 'Account::Transaction' AND outflow_candidates.entryable_type = 'Account::Transaction'")
.where(existing_transfers: { id: nil })
.order("date_diff ASC") # Closest matches first
end
def auto_match_transfers!
# Exclude already matched transfers
candidates_scope = transfer_match_candidates.where(rejected_transfers: { id: nil })
# Track which transactions we've already matched to avoid duplicates
used_transaction_ids = Set.new
candidates = []
Transfer.transaction do
candidates_scope.each do |match|
next if used_transaction_ids.include?(match.inflow_transaction_id) ||
used_transaction_ids.include?(match.outflow_transaction_id)
Transfer.create!(
inflow_transaction_id: match.inflow_transaction_id,
outflow_transaction_id: match.outflow_transaction_id,
)
used_transaction_ids << match.inflow_transaction_id
used_transaction_ids << match.outflow_transaction_id
end
end
end
end

View File

@@ -11,7 +11,7 @@ class Account::BalanceCalculator
holdings_value = converted_holdings.select { |h| h.date == balance.date }.sum(&:amount)
balance.balance = balance.balance + holdings_value
balance
end
end.compact
end
private

View File

@@ -0,0 +1,56 @@
class Account::DataEnricher
include Providable
attr_reader :account
def initialize(account)
@account = account
end
def run
enrich_transactions
end
private
def enrich_transactions
candidates = account.entries.account_transactions.includes(entryable: [ :merchant, :category ])
Rails.logger.info("Enriching #{candidates.count} transactions for account #{account.id}")
merchants = {}
candidates.each do |entry|
if entry.enriched_at.nil? || entry.entryable.merchant_id.nil? || entry.entryable.category_id.nil?
begin
next unless entry.name.present?
info = self.class.synth_provider.enrich_transaction(entry.name).info
next unless info.present?
if info.name.present?
merchant = merchants[info.name] ||= account.family.merchants.find_or_create_by(name: info.name)
if info.icon_url.present?
merchant.icon_url = info.icon_url
end
end
entryable_attributes = { id: entry.entryable_id }
entryable_attributes[:merchant_id] = merchant.id if merchant.present? && entry.entryable.merchant_id.nil?
Account.transaction do
merchant.save! if merchant.present?
entry.update!(
enriched_at: Time.current,
enriched_name: info.name,
entryable_attributes: entryable_attributes
)
end
rescue => e
Rails.logger.warn("Error enriching transaction #{entry.id}: #{e.message}")
end
end
end
end
end

View File

@@ -1,6 +1,8 @@
class Account::Entry < ApplicationRecord
include Monetizable
Stats = Struct.new(:currency, :count, :income_total, :expense_total, keyword_init: true)
monetize :amount
belongs_to :account
@@ -10,14 +12,14 @@ class Account::Entry < ApplicationRecord
delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy
accepts_nested_attributes_for :entryable
validates :date, :amount, :currency, presence: true
validates :date, :name, :amount, :currency, presence: true
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
validates :date, comparison: { greater_than: -> { min_supported_date } }
scope :chronological, -> {
order(
date: :asc,
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
created_at: :asc
)
}
@@ -25,12 +27,29 @@ class Account::Entry < ApplicationRecord
scope :reverse_chronological, -> {
order(
date: :desc,
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
created_at: :desc
)
}
scope :without_transfers, -> { where(marked_as_transfer: false) }
# All non-transfer entries, rejected transfers, and the outflow of a loan payment transfer are incomes/expenses
scope :incomes_and_expenses, -> {
joins("INNER JOIN account_transactions ON account_transactions.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_transactions.id OR transfers.outflow_transaction_id = account_transactions.id")
.joins("LEFT JOIN account_transactions inflow_txns ON inflow_txns.id = transfers.inflow_transaction_id")
.joins("LEFT JOIN account_entries inflow_entries ON inflow_entries.entryable_id = inflow_txns.id AND inflow_entries.entryable_type = 'Account::Transaction'")
.joins("LEFT JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_entries.account_id")
.where("transfers.id IS NULL OR transfers.status = 'rejected' OR (account_entries.amount > 0 AND inflow_accounts.accountable_type = 'Loan')")
}
scope :incomes, -> {
incomes_and_expenses.where("account_entries.amount <= 0")
}
scope :expenses, -> {
incomes_and_expenses.where("account_entries.amount > 0")
}
scope :with_converted_amount, ->(currency) {
# Join with exchange rates to convert the amount to the given currency
# If no rate is available, exclude the transaction from the results
@@ -47,14 +66,6 @@ class Account::Entry < ApplicationRecord
account.sync_later(start_date: sync_start_date)
end
def inflow?
amount <= 0 && account_transaction?
end
def outflow?
amount > 0 && account_transaction?
end
def entryable_name_short
entryable_type.demodulize.underscore
end
@@ -63,7 +74,32 @@ class Account::Entry < ApplicationRecord
Account::BalanceTrendCalculator.new(self, entries, balances).trend
end
def display_name
enriched_name.presence || name
end
def transfer_match_candidates
candidates_scope = account.transfer_match_candidates
candidates_scope = if amount.negative?
candidates_scope.where("inflow_candidates.entryable_id = ?", entryable_id)
else
candidates_scope.where("outflow_candidates.entryable_id = ?", entryable_id)
end
candidates_scope.map do |pm|
Transfer.new(
inflow_transaction_id: pm.inflow_transaction_id,
outflow_transaction_id: pm.outflow_transaction_id,
)
end
end
class << self
def search(params)
Account::EntrySearch.new(params).build_query(all)
end
# arbitrary cutoff date to avoid expensive sync operations
def min_supported_date
30.years.ago.to_date
@@ -98,13 +134,6 @@ class Account::Entry < ApplicationRecord
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
end
def mark_transfers!
update_all marked_as_transfer: true
# Attempt to "auto match" and save a transfer if 2 transactions selected
Account::Transfer.new(entries: all).save if all.count == 2
end
def bulk_update!(bulk_update_params)
bulk_attributes = {
date: bulk_update_params[:date],
@@ -127,81 +156,24 @@ class Account::Entry < ApplicationRecord
all.size
end
def income_total(currency = "USD")
total = without_transfers.account_transactions.includes(:entryable)
.where("account_entries.amount <= 0")
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum
def stats(currency = "USD")
result = all
.incomes_and_expenses
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.from_currency AND er.to_currency = ?", currency ]))
.select(
"COUNT(*) AS count",
"SUM(CASE WHEN account_entries.amount < 0 THEN (account_entries.amount * COALESCE(er.rate, 1)) ELSE 0 END) AS income_total",
"SUM(CASE WHEN account_entries.amount > 0 THEN (account_entries.amount * COALESCE(er.rate, 1)) ELSE 0 END) AS expense_total"
)
.to_a
.first
Money.new(total, currency)
Stats.new(
currency: currency,
count: result.count,
income_total: result.income_total ? result.income_total * -1 : 0,
expense_total: result.expense_total || 0
)
end
def expense_total(currency = "USD")
total = without_transfers.account_transactions.includes(:entryable)
.where("account_entries.amount > 0")
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum
Money.new(total, currency)
end
def search(params)
query = all
query = query.where("account_entries.name ILIKE ?", "%#{sanitize_sql_like(params[:search])}%") if params[:search].present?
query = query.where("account_entries.date >= ?", params[:start_date]) if params[:start_date].present?
query = query.where("account_entries.date <= ?", params[:end_date]) if params[:end_date].present?
if params[:types].present?
query = query.where(marked_as_transfer: false) unless params[:types].include?("transfer")
if params[:types].include?("income") && !params[:types].include?("expense")
query = query.where("account_entries.amount < 0")
elsif params[:types].include?("expense") && !params[:types].include?("income")
query = query.where("account_entries.amount >= 0")
end
end
if params[:amount].present? && params[:amount_operator].present?
case params[:amount_operator]
when "equal"
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", params[:amount].to_f.abs)
when "less"
query = query.where("ABS(account_entries.amount) < ?", params[:amount].to_f.abs)
when "greater"
query = query.where("ABS(account_entries.amount) > ?", params[:amount].to_f.abs)
end
end
if params[:accounts].present? || params[:account_ids].present?
query = query.joins(:account)
end
query = query.where(accounts: { name: params[:accounts] }) if params[:accounts].present?
query = query.where(accounts: { id: params[:account_ids] }) if params[:account_ids].present?
# Search attributes on each entryable to further refine results
entryable_ids = entryable_search(params)
query = query.where(entryable_id: entryable_ids) unless entryable_ids.nil?
query
end
private
def entryable_search(params)
entryable_ids = []
entryable_search_performed = false
Account::Entryable::TYPES.map(&:constantize).each do |entryable|
next unless entryable.requires_search?(params)
entryable_search_performed = true
entryable_ids += entryable.search(params).pluck(:id)
end
return nil unless entryable_search_performed
entryable_ids
end
end
end

View File

@@ -0,0 +1,57 @@
class Account::EntrySearch
include ActiveModel::Model
include ActiveModel::Attributes
attribute :search, :string
attribute :amount, :string
attribute :amount_operator, :string
attribute :types, :string
attribute :accounts, array: true
attribute :account_ids, array: true
attribute :start_date, :string
attribute :end_date, :string
class << self
def from_entryable_search(entryable_search)
new(entryable_search.attributes.slice(*attribute_names))
end
end
def build_query(scope)
query = scope
query = query.where("account_entries.name ILIKE :search OR account_entries.enriched_name ILIKE :search",
search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
) if search.present?
query = query.where("account_entries.date >= ?", start_date) if start_date.present?
query = query.where("account_entries.date <= ?", end_date) if end_date.present?
if types.present?
if types.include?("income") && !types.include?("expense")
query = query.where("account_entries.amount < 0")
elsif types.include?("expense") && !types.include?("income")
query = query.where("account_entries.amount >= 0")
end
end
if amount.present? && amount_operator.present?
case amount_operator
when "equal"
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", amount.to_f.abs)
when "less"
query = query.where("ABS(account_entries.amount) < ?", amount.to_f.abs)
when "greater"
query = query.where("ABS(account_entries.amount) > ?", amount.to_f.abs)
end
end
if accounts.present? || account_ids.present?
query = query.joins(:account)
end
query = query.where(accounts: { name: accounts }) if accounts.present?
query = query.where(accounts: { id: account_ids }) if account_ids.present?
query
end
end

View File

@@ -48,8 +48,9 @@ class Account::HoldingCalculator
def generate_holding_records(portfolio, date)
portfolio.map do |security_id, qty|
security = securities_cache[security_id]
price = security.dig(:prices)&.find { |p| p.date == date }
next if security.nil?
price = security.dig(:prices)&.find { |p| p.date == date }
next if price.blank?
converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount
@@ -98,7 +99,7 @@ class Account::HoldingCalculator
end
def trades
@trades ||= account.entries.includes(entryable: :security).account_trades.to_a
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
end
def portfolio_start_date
@@ -106,7 +107,10 @@ class Account::HoldingCalculator
end
def preload_securities
# Get securities from trades and current holdings
securities = trades.map(&:entryable).map(&:security).uniq
securities += account.holdings.where(date: Date.current).map(&:security)
securities.uniq!
securities.each do |security|
prices = Security::Price.find_prices(

View File

@@ -5,11 +5,20 @@ class Account::Syncer
end
def run
account.auto_match_transfers!
holdings = sync_holdings
balances = sync_balances(holdings)
account.reload
update_account_info(balances, holdings) unless account.plaid_account_id.present?
convert_records_to_family_currency(balances, holdings) unless account.currency == account.family.currency
# Enrich if user opted in or if we're syncing transactions from a Plaid account on the hosted app
if account.family.data_enrichment_enabled? || (account.plaid_account_id.present? && Rails.application.config.app_mode.hosted?)
account.enrich_data_later
else
Rails.logger.info("Data enrichment is disabled, skipping enrichment for account #{account.id}")
end
end
private
@@ -67,29 +76,33 @@ class Account::Syncer
exchange_rates = ExchangeRate.find_rates(
from: from_currency,
to: to_currency,
start_date: balances.first.date
start_date: balances.min_by(&:date).date
)
converted_balances = balances.map do |balance|
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
next unless exchange_rate.present?
account.balances.build(
date: balance.date,
balance: exchange_rate.rate * balance.balance,
currency: to_currency
) if exchange_rate.present?
end
)
end.compact
converted_holdings = holdings.map do |holding|
exchange_rate = exchange_rates.find { |er| er.date == holding.date }
next unless exchange_rate.present?
account.holdings.build(
security: holding.security,
date: holding.date,
amount: exchange_rate.rate * holding.amount,
currency: to_currency
) if exchange_rate.present?
end
)
end.compact
Account.transaction do
load_balances(converted_balances)

View File

@@ -8,31 +8,8 @@ class Account::Trade < ApplicationRecord
validates :qty, presence: true
validates :price, :currency, presence: true
class << self
def search(_params)
all
end
def requires_search?(_params)
false
end
end
def sell?
qty < 0
end
def buy?
qty > 0
end
def name
prefix = sell? ? "Sell " : "Buy "
prefix + "#{qty.abs} shares of #{security.ticker}"
end
def unrealized_gain_loss
return nil if sell?
return nil if qty.negative?
current_price = security.current_price
return nil if current_price.nil?

View File

@@ -4,6 +4,13 @@ class Account::TradeBuilder
attr_accessor :account, :date, :amount, :currency, :qty,
:price, :ticker, :type, :transfer_account_id
attr_reader :buildable
def initialize(attributes = {})
super
@buildable = set_buildable
end
def save
buildable.save
end
@@ -17,7 +24,7 @@ class Account::TradeBuilder
end
private
def buildable
def set_buildable
case type
when "buy", "sell"
build_trade
@@ -31,7 +38,11 @@ class Account::TradeBuilder
end
def build_trade
prefix = type == "sell" ? "Sell " : "Buy "
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
account.entries.new(
name: trade_name,
date: date,
amount: signed_amount,
currency: currency,
@@ -51,9 +62,9 @@ class Account::TradeBuilder
from_account = type == "withdrawal" ? account : transfer_account
to_account = type == "withdrawal" ? transfer_account : account
Account::Transfer.build_from_accounts(
from_account,
to_account,
Transfer.from_accounts(
from_account: from_account,
to_account: to_account,
date: date,
amount: signed_amount
)
@@ -63,7 +74,6 @@ class Account::TradeBuilder
date: date,
amount: signed_amount,
currency: currency,
marked_as_transfer: true,
entryable: Account::Transaction.new
)
end

View File

@@ -6,62 +6,28 @@ class Account::Transaction < ApplicationRecord
has_many :taggings, as: :taggable, dependent: :destroy
has_many :tags, through: :taggings
has_one :transfer_as_inflow, class_name: "Transfer", foreign_key: "inflow_transaction_id", dependent: :destroy
has_one :transfer_as_outflow, class_name: "Transfer", foreign_key: "outflow_transaction_id", dependent: :destroy
# We keep track of rejected transfers to avoid auto-matching them again
has_one :rejected_transfer_as_inflow, class_name: "RejectedTransfer", foreign_key: "inflow_transaction_id", dependent: :destroy
has_one :rejected_transfer_as_outflow, class_name: "RejectedTransfer", foreign_key: "outflow_transaction_id", dependent: :destroy
accepts_nested_attributes_for :taggings, allow_destroy: true
scope :active, -> { where(excluded: false) }
class << self
def search(params)
query = all
if params[:categories].present?
if params[:categories].exclude?("Uncategorized")
query = query
.joins(:category)
.where(categories: { name: params[:categories] })
else
query = query
.left_joins(:category)
.where(categories: { name: params[:categories] })
.or(query.where(category_id: nil))
end
end
query = query.joins(:merchant).where(merchants: { name: params[:merchants] }) if params[:merchants].present?
if params[:tags].present?
query = query.joins(:tags)
.where(tags: { name: params[:tags] })
.distinct
end
query
Account::TransactionSearch.new(params).build_query(all)
end
def requires_search?(params)
searchable_keys.any? { |key| params.key?(key) }
end
private
def searchable_keys
%i[categories merchants tags]
end
end
def name
entry.name || "(no description)"
def transfer
transfer_as_inflow || transfer_as_outflow
end
def eod_balance
entry.amount_money
def transfer?
transfer.present?
end
private
def account
entry.account
end
def daily_transactions
account.entries.account_transactions
end
end

View File

@@ -0,0 +1,47 @@
class Account::TransactionSearch
include ActiveModel::Model
include ActiveModel::Attributes
attribute :search, :string
attribute :amount, :string
attribute :amount_operator, :string
attribute :types, array: true
attribute :accounts, array: true
attribute :account_ids, array: true
attribute :start_date, :string
attribute :end_date, :string
attribute :categories, array: true
attribute :merchants, array: true
attribute :tags, array: true
# Returns array of Account::Entry objects to stay consistent with partials, which only deal with Account::Entry
def build_query(scope)
query = scope
if types.present? && types.exclude?("transfer")
query = query.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_entries.id OR transfers.outflow_transaction_id = account_entries.id")
.where("transfers.id IS NULL")
end
if categories.present?
if categories.exclude?("Uncategorized")
query = query
.joins(:category)
.where(categories: { name: categories })
else
query = query
.left_joins(:category)
.where(categories: { name: categories })
.or(query.where(category_id: nil))
end
end
query = query.joins(:merchant).where(merchants: { name: merchants }) if merchants.present?
query = query.joins(:tags).where(tags: { name: tags }) if tags.present?
entries_scope = Account::Entry.account_transactions.where(entryable_id: query.select(:id))
Account::EntrySearch.from_entryable_search(self).build_query(entries_scope)
end
end

View File

@@ -1,113 +0,0 @@
class Account::Transfer < ApplicationRecord
has_many :entries, dependent: :destroy
validate :net_zero_flows, if: :single_currency_transfer?
validate :transaction_count, :from_different_accounts, :all_transactions_marked
def date
outflow_transaction&.date
end
def amount_money
entries.first&.amount_money&.abs || Money.new(0)
end
def from_name
from_account&.name || I18n.t("account/transfer.from_fallback_name")
end
def to_name
to_account&.name || I18n.t("account/transfer.to_fallback_name")
end
def name
I18n.t("account/transfer.name", from_account: from_name, to_account: to_name)
end
def from_account
outflow_transaction&.account
end
def to_account
inflow_transaction&.account
end
def inflow_transaction
entries.find { |e| e.inflow? }
end
def outflow_transaction
entries.find { |e| e.outflow? }
end
def update_entries!(params)
transaction do
entries.each do |entry|
entry.update!(params)
end
end
end
def sync_account_later
entries.each(&:sync_account_later)
end
class << self
def build_from_accounts(from_account, to_account, date:, amount:)
outflow = from_account.entries.build \
amount: amount.abs,
currency: from_account.currency,
date: date,
name: "Transfer to #{to_account.name}",
marked_as_transfer: true,
entryable: Account::Transaction.new
# Attempt to convert the amount to the to_account's currency. If the conversion fails,
# use the original amount.
converted_amount = begin
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
rescue Money::ConversionError
Money.new(amount.abs, from_account.currency)
end
inflow = to_account.entries.build \
amount: converted_amount.amount * -1,
currency: converted_amount.currency.iso_code,
date: date,
name: "Transfer from #{from_account.name}",
marked_as_transfer: true,
entryable: Account::Transaction.new
new entries: [ outflow, inflow ]
end
end
private
def single_currency_transfer?
entries.map { |e| e.currency }.uniq.size == 1
end
def transaction_count
unless entries.size == 2
errors.add :entries, :must_have_exactly_2_entries
end
end
def from_different_accounts
accounts = entries.map { |e| e.account_id }.uniq
errors.add :entries, :must_be_from_different_accounts if accounts.size < entries.size
end
def net_zero_flows
unless entries.sum(&:amount).zero?
errors.add :entries, :must_have_an_inflow_and_outflow_that_net_to_zero
end
end
def all_transactions_marked
unless entries.all?(&:marked_as_transfer)
errors.add :entries, :must_be_marked_as_transfer
end
end
end

View File

@@ -1,13 +1,3 @@
class Account::Valuation < ApplicationRecord
include Account::Entryable
class << self
def search(_params)
all
end
def requires_search?(_params)
false
end
end
end

182
app/models/budget.rb Normal file
View File

@@ -0,0 +1,182 @@
class Budget < ApplicationRecord
include Monetizable
belongs_to :family
has_many :budget_categories, dependent: :destroy
validates :start_date, :end_date, presence: true
validates :start_date, :end_date, uniqueness: { scope: :family_id }
monetize :budgeted_spending, :expected_income, :allocated_spending,
:actual_spending, :available_to_spend, :available_to_allocate,
:estimated_spending, :estimated_income, :actual_income, :remaining_expected_income
class << self
def for_date(date)
find_by(start_date: date.beginning_of_month, end_date: date.end_of_month)
end
def find_or_bootstrap(family, date: Date.current)
Budget.transaction do
budget = Budget.find_or_create_by!(
family: family,
start_date: date.beginning_of_month,
end_date: date.end_of_month
) do |b|
b.currency = family.currency
end
budget.sync_budget_categories
budget
end
end
end
def sync_budget_categories
family.categories.expenses.each do |category|
budget_categories.find_or_create_by(
category: category,
) do |bc|
bc.budgeted_spending = 0
bc.currency = family.currency
end
end
end
def uncategorized_budget_category
budget_categories.uncategorized.tap do |bc|
bc.budgeted_spending = [ available_to_allocate, 0 ].max
bc.currency = family.currency
end
end
def entries
family.entries.incomes_and_expenses.where(date: start_date..end_date)
end
def name
start_date.strftime("%B %Y")
end
def initialized?
budgeted_spending.present?
end
def income_categories_with_totals
family.income_categories_with_totals(date: start_date)
end
def expense_categories_with_totals
family.expense_categories_with_totals(date: start_date)
end
def current?
start_date == Date.today.beginning_of_month && end_date == Date.today.end_of_month
end
def previous_budget
prev_month_end_date = end_date - 1.month
return nil if prev_month_end_date < family.oldest_entry_date
family.budgets.find_or_bootstrap(family, date: prev_month_end_date)
end
def next_budget
return nil if current?
next_start_date = start_date + 1.month
family.budgets.find_or_bootstrap(family, date: next_start_date)
end
def to_donut_segments_json
unused_segment_id = "unused"
# Continuous gray segment for empty budgets
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless allocations_valid?
segments = budget_categories.includes(:category).map do |bc|
{ color: bc.category.color, amount: bc.actual_spending, id: bc.id }
end
if available_to_spend.positive?
segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id })
end
segments
end
# =============================================================================
# Actuals: How much user has spent on each budget category
# =============================================================================
def estimated_spending
family.budgeting_stats.avg_monthly_expenses&.abs
end
def actual_spending
expense_categories_with_totals.total_money.amount
end
def available_to_spend
(budgeted_spending || 0) - actual_spending
end
def percent_of_budget_spent
return 0 unless budgeted_spending > 0
(actual_spending / budgeted_spending.to_f) * 100
end
def overage_percent
return 0 unless available_to_spend.negative?
available_to_spend.abs / actual_spending.to_f * 100
end
# =============================================================================
# Budget allocations: How much user has budgeted for all categories combined
# =============================================================================
def allocated_spending
budget_categories.sum(:budgeted_spending)
end
def allocated_percent
return 0 unless budgeted_spending && budgeted_spending > 0
(allocated_spending / budgeted_spending.to_f) * 100
end
def available_to_allocate
(budgeted_spending || 0) - allocated_spending
end
def allocations_valid?
initialized? && available_to_allocate.positive? && allocated_spending > 0
end
# =============================================================================
# Income: How much user earned relative to what they expected to earn
# =============================================================================
def estimated_income
family.budgeting_stats.avg_monthly_income&.abs
end
def actual_income
family.entries.incomes.where(date: start_date..end_date).sum(:amount).abs
end
def actual_income_percent
return 0 unless expected_income > 0
(actual_income / expected_income.to_f) * 100
end
def remaining_expected_income
expected_income - actual_income
end
def surplus_percent
return 0 unless remaining_expected_income.negative?
remaining_expected_income.abs / expected_income.to_f * 100
end
end

View File

@@ -0,0 +1,82 @@
class BudgetCategory < ApplicationRecord
include Monetizable
belongs_to :budget
belongs_to :category
validates :budget_id, uniqueness: { scope: :category_id }
monetize :budgeted_spending, :actual_spending, :available_to_spend
class Group
attr_reader :budget_category, :budget_subcategories
delegate :category, to: :budget_category
delegate :name, :color, to: :category
def self.for(budget_categories)
top_level_categories = budget_categories.select { |budget_category| budget_category.category.parent_id.nil? }
top_level_categories.map do |top_level_category|
subcategories = budget_categories.select { |bc| bc.category.parent_id == top_level_category.category_id && top_level_category.category_id.present? }
new(top_level_category, subcategories.sort_by { |subcategory| subcategory.category.name })
end.sort_by { |group| group.category.name }
end
def initialize(budget_category, budget_subcategories = [])
@budget_category = budget_category
@budget_subcategories = budget_subcategories
end
end
class << self
def uncategorized
new(
id: Digest::UUID.uuid_v5(Digest::UUID::URL_NAMESPACE, "uncategorized"),
category: nil,
)
end
end
def initialized?
budget.initialized?
end
def category
super || budget.family.categories.uncategorized
end
def subcategory?
category.parent_id.present?
end
def actual_spending
category.month_total(date: budget.start_date)
end
def available_to_spend
(budgeted_spending || 0) - actual_spending
end
def percent_of_budget_spent
return 0 unless budgeted_spending > 0
(actual_spending / budgeted_spending) * 100
end
def to_donut_segments_json
unused_segment_id = "unused"
overage_segment_id = "overage"
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless actual_spending > 0
segments = [ { color: category.color, amount: actual_spending, id: id } ]
if available_to_spend.negative?
segments.push({ color: "#EF4444", amount: available_to_spend.abs, id: overage_segment_id })
else
segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id })
end
segments
end
end

View File

@@ -0,0 +1,29 @@
class BudgetingStats
attr_reader :family
def initialize(family)
@family = family
end
def avg_monthly_income
income_expense_totals_query(Account::Entry.incomes)
end
def avg_monthly_expenses
income_expense_totals_query(Account::Entry.expenses)
end
private
def income_expense_totals_query(type_scope)
monthly_totals = family.entries
.merge(type_scope)
.select("SUM(account_entries.amount) as total")
.group(Arel.sql("date_trunc('month', account_entries.date)"))
result = Family.select("AVG(mt.total)")
.from(monthly_totals, :mt)
.pick("AVG(mt.total)")
result
end
end

View File

@@ -1,43 +1,96 @@
class Category < ApplicationRecord
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
belongs_to :family
has_many :budget_categories, dependent: :destroy
has_many :subcategories, class_name: "Category", foreign_key: :parent_id
belongs_to :parent, class_name: "Category", optional: true
validates :name, :color, :family, presence: true
validates :name, uniqueness: { scope: :family_id }
before_update :clear_internal_category, if: :name_changed?
validate :category_level_limit
validate :nested_category_matches_parent_classification
before_create :inherit_color_from_parent
scope :alphabetically, -> { order(:name) }
scope :roots, -> { where(parent_id: nil) }
scope :incomes, -> { where(classification: "income") }
scope :expenses, -> { where(classification: "expense") }
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
UNCATEGORIZED_COLOR = "#737373"
TRANSFER_COLOR = "#444CE7"
PAYMENT_COLOR = "#db5a54"
TRADE_COLOR = "#e99537"
DEFAULT_CATEGORIES = [
{ internal_category: "income", color: COLORS[0] },
{ internal_category: "food_and_drink", color: COLORS[1] },
{ internal_category: "entertainment", color: COLORS[2] },
{ internal_category: "personal_care", color: COLORS[3] },
{ internal_category: "general_services", color: COLORS[4] },
{ internal_category: "auto_and_transport", color: COLORS[5] },
{ internal_category: "rent_and_utilities", color: COLORS[6] },
{ internal_category: "home_improvement", color: COLORS[7] }
]
class Group
attr_reader :category, :subcategories
def self.create_default_categories(family)
if family.categories.size > 0
raise ArgumentError, "Family already has some categories"
delegate :name, :color, to: :category
def self.for(categories)
categories.select { |category| category.parent_id.nil? }.map do |category|
new(category, category.subcategories)
end
end
family_id = family.id
categories = self::DEFAULT_CATEGORIES.map { |c| {
name: I18n.t("transaction.default_category.#{c[:internal_category]}"),
internal_category: c[:internal_category],
color: c[:color],
family_id:
} }
self.insert_all(categories)
def initialize(category, subcategories = nil)
@category = category
@subcategories = subcategories || []
end
end
class << self
def icon_codes
%w[bus circle-dollar-sign ambulance apple award baby battery lightbulb bed-single beer bluetooth book briefcase building credit-card camera utensils cooking-pot cookie dices drama dog drill drum dumbbell gamepad-2 graduation-cap house hand-helping ice-cream-cone phone piggy-bank pill pizza printer puzzle ribbon shopping-cart shield-plus ticket trees]
end
def bootstrap_defaults
default_categories.each do |name, color, icon|
find_or_create_by!(name: name) do |category|
category.color = color
category.classification = "income" if name == "Income"
category.lucide_icon = icon
end
end
end
def uncategorized
new(
name: "Uncategorized",
color: UNCATEGORIZED_COLOR,
lucide_icon: "circle-dashed"
)
end
private
def default_categories
[
[ "Income", "#e99537", "circle-dollar-sign" ],
[ "Housing", "#6471eb", "house" ],
[ "Entertainment", "#df4e92", "drama" ],
[ "Food & Drink", "#eb5429", "utensils" ],
[ "Shopping", "#e99537", "shopping-cart" ],
[ "Healthcare", "#4da568", "pill" ],
[ "Insurance", "#6471eb", "piggy-bank" ],
[ "Utilities", "#db5a54", "lightbulb" ],
[ "Transportation", "#df4e92", "bus" ],
[ "Education", "#eb5429", "book" ],
[ "Gifts & Donations", "#61c9ea", "hand-helping" ],
[ "Subscriptions", "#805dee", "credit-card" ]
]
end
end
def inherit_color_from_parent
if subcategory?
self.color = parent.color
end
end
def replace_and_destroy!(replacement)
@@ -47,9 +100,48 @@ class Category < ApplicationRecord
end
end
private
def parent?
subcategories.any?
end
def clear_internal_category
self.internal_category = nil
def subcategory?
parent.present?
end
def avg_monthly_total
family.category_stats.avg_monthly_total_for(self)
end
def median_monthly_total
family.category_stats.median_monthly_total_for(self)
end
def month_total(date: Date.current)
family.category_stats.month_total_for(self, date: date)
end
def avg_monthly_total_money
Money.new(avg_monthly_total, family.currency)
end
def median_monthly_total_money
Money.new(median_monthly_total, family.currency)
end
def month_total_money(date: Date.current)
Money.new(month_total(date: date), family.currency)
end
private
def category_level_limit
if (subcategory? && parent.subcategory?) || (parent? && subcategory?)
errors.add(:parent, "can't have more than 2 levels of subcategories")
end
end
def nested_category_matches_parent_classification
if subcategory? && parent.classification != classification
errors.add(:parent, "must have the same classification as its parent")
end
end
end

View File

@@ -0,0 +1,179 @@
class CategoryStats
attr_reader :family
def initialize(family)
@family = family
end
def avg_monthly_total_for(category)
statistics_data[category.id]&.avg || 0
end
def median_monthly_total_for(category)
statistics_data[category.id]&.median || 0
end
def month_total_for(category, date: Date.current)
monthly_totals = totals_data[category.id]
category_total = monthly_totals&.find { |mt| mt.month == date.month && mt.year == date.year }
category_total&.amount || 0
end
def month_category_totals(date: Date.current)
by_classification = Hash.new { |h, k| h[k] = {} }
totals_data.each_with_object(by_classification) do |(category_id, totals), result|
totals.each do |t|
next unless t.month == date.month && t.year == date.year
result[t.classification][category_id] ||= { amount: 0, subcategory: t.subcategory? }
result[t.classification][category_id][:amount] += t.amount.abs
end
end
# Calculate percentages for each group
category_totals = []
[ "income", "expense" ].each do |classification|
totals = by_classification[classification]
# Only include non-subcategory amounts in the total for percentage calculations
total_amount = totals.sum do |_, data|
data[:subcategory] ? 0 : data[:amount]
end
next if total_amount.zero?
totals.each do |category_id, data|
percentage = (data[:amount].to_f / total_amount * 100).round(1)
category_totals << CategoryTotal.new(
category_id: category_id,
amount: data[:amount],
percentage: percentage,
classification: classification,
currency: family.currency,
subcategory?: data[:subcategory]
)
end
end
# Calculate totals based on non-subcategory amounts only
total_income = category_totals
.select { |ct| ct.classification == "income" && !ct.subcategory? }
.sum(&:amount)
total_expense = category_totals
.select { |ct| ct.classification == "expense" && !ct.subcategory? }
.sum(&:amount)
CategoryTotals.new(
total_income: total_income,
total_expense: total_expense,
category_totals: category_totals
)
end
private
Totals = Struct.new(:month, :year, :amount, :classification, :currency, :subcategory?, keyword_init: true)
Stats = Struct.new(:avg, :median, :currency, keyword_init: true)
CategoryTotals = Struct.new(:total_income, :total_expense, :category_totals, keyword_init: true)
CategoryTotal = Struct.new(:category_id, :amount, :percentage, :classification, :currency, :subcategory?, keyword_init: true)
def statistics_data
@statistics_data ||= begin
stats = totals_data.each_with_object({ nil => Stats.new(avg: 0, median: 0) }) do |(category_id, totals), hash|
next if totals.empty?
amounts = totals.map(&:amount)
hash[category_id] = Stats.new(
avg: (amounts.sum.to_f / amounts.size).round,
median: calculate_median(amounts),
currency: family.currency
)
end
end
end
def totals_data
@totals_data ||= begin
totals = monthly_totals_query.each_with_object({ nil => [] }) do |row, hash|
hash[row.category_id] ||= []
existing_total = hash[row.category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
if existing_total
existing_total.amount += row.total.to_i
else
hash[row.category_id] << Totals.new(
month: row.date.month,
year: row.date.year,
amount: row.total.to_i,
classification: row.classification,
currency: family.currency,
subcategory?: row.parent_category_id.present?
)
end
# If category is a parent, its total includes its own transactions + sum(child category transactions)
if row.parent_category_id
hash[row.parent_category_id] ||= []
existing_parent_total = hash[row.parent_category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
if existing_parent_total
existing_parent_total.amount += row.total.to_i
else
hash[row.parent_category_id] << Totals.new(
month: row.date.month,
year: row.date.year,
amount: row.total.to_i,
classification: row.classification,
currency: family.currency,
subcategory?: false
)
end
end
end
# Ensure we have a default empty array for nil category, which represents "Uncategorized"
totals[nil] ||= []
totals
end
end
def monthly_totals_query
income_expense_classification = Arel.sql("
CASE WHEN categories.id IS NULL THEN
CASE WHEN account_entries.amount < 0 THEN 'income' ELSE 'expense' END
ELSE categories.classification
END
")
family.entries
.incomes_and_expenses
.select(
"categories.id as category_id",
"categories.parent_id as parent_category_id",
income_expense_classification,
"date_trunc('month', account_entries.date) as date",
"SUM(account_entries.amount) as total"
)
.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
.group(Arel.sql("categories.id, categories.parent_id, #{income_expense_classification}, date_trunc('month', account_entries.date)"))
.order(Arel.sql("date_trunc('month', account_entries.date) DESC"))
end
def calculate_median(numbers)
return 0 if numbers.empty?
sorted = numbers.sort
mid = sorted.size / 2
if sorted.size.odd?
sorted[mid]
else
((sorted[mid-1] + sorted[mid]) / 2.0).round
end
end
end

View File

@@ -2,13 +2,25 @@ module Plaidable
extend ActiveSupport::Concern
class_methods do
def plaid_provider
Provider::Plaid.new if Rails.application.config.plaid
def plaid_us_provider
Provider::Plaid.new(Rails.application.config.plaid, :us) if Rails.application.config.plaid
end
def plaid_eu_provider
Provider::Plaid.new(Rails.application.config.plaid_eu, :eu) if Rails.application.config.plaid_eu
end
def plaid_provider_for_region(region)
region.to_sym == :eu ? plaid_eu_provider : plaid_us_provider
end
end
private
def eu?
raise "eu? is not implemented for #{self.class.name}"
end
def plaid_provider
self.class.plaid_provider
eu? ? self.class.plaid_eu_provider : self.class.plaid_us_provider
end
end

View File

@@ -1,98 +1,124 @@
class Demo::Generator
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
def initialize
@family = reset_family!
end
# Builds a semi-realistic mirror of what production data might look like
def reset_and_clear_data!(family_names)
puts "Clearing existing data..."
def reset_and_clear_data!
reset_settings!
clear_data!
create_user!
destroy_everything!
puts "user reset"
end
puts "Data cleared"
def reset_data!
Family.transaction do
reset_settings!
clear_data!
create_user!
puts "user reset"
create_tags!
create_categories!
create_merchants!
puts "tags, categories, merchants created"
create_credit_card_account!
create_checking_account!
create_savings_account!
create_investment_account!
create_house_and_mortgage!
create_car_and_loan!
create_other_accounts!
puts "accounts created"
puts "Demo data loaded successfully!"
family_names.each_with_index do |family_name, index|
create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local")
end
puts "Users reset"
end
def reset_data!(family_names)
puts "Clearing existing data..."
destroy_everything!
puts "Data cleared"
family_names.each_with_index do |family_name, index|
create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", data_enrichment_enabled: index == 0)
end
puts "Users reset"
load_securities!
puts "Securities loaded"
family_names.each do |family_name|
family = Family.find_by(name: family_name)
ActiveRecord::Base.transaction do
create_tags!(family)
create_categories!(family)
create_merchants!(family)
puts "tags, categories, merchants created for #{family_name}"
create_credit_card_account!(family)
create_checking_account!(family)
create_savings_account!(family)
create_investment_account!(family)
create_house_and_mortgage!(family)
create_car_and_loan!(family)
create_other_accounts!(family)
create_transfer_transactions!(family)
end
puts "accounts created for #{family_name}"
end
puts "Demo data loaded successfully!"
end
private
attr_reader :family
def reset_family!
family_id = "d99e3c6e-d513-4452-8f24-dc263f8528c0" # deterministic demo id
family = Family.find_by(id: family_id)
family.destroy! if family
Family.create!(id: family_id, name: "Demo Family", stripe_subscription_status: "active").tap(&:reload)
end
def clear_data!
def destroy_everything!
Family.destroy_all
Setting.destroy_all
InviteCode.destroy_all
User.find_by_email("user@maybe.local")&.destroy
ExchangeRate.destroy_all
Security.destroy_all
Security::Price.destroy_all
end
def reset_settings!
Setting.destroy_all
end
def create_family_and_user!(family_name, user_email, data_enrichment_enabled: false)
base_uuid = "d99e3c6e-d513-4452-8f24-dc263f8528c0"
id = Digest::UUID.uuid_v5(base_uuid, family_name)
family = Family.create!(
id: id,
name: family_name,
stripe_subscription_status: "active",
data_enrichment_enabled: data_enrichment_enabled,
locale: "en",
country: "US",
timezone: "America/New_York",
date_format: "%m-%d-%Y"
)
def create_user!
family.users.create! \
email: "user@maybe.local",
email: user_email,
first_name: "Demo",
last_name: "User",
role: "admin",
password: "password",
onboarded_at: Time.current
family.users.create! \
email: "member_#{user_email}",
first_name: "Demo (member user)",
last_name: "User",
role: "member",
password: "password",
onboarded_at: Time.current
end
def create_tags!
def create_tags!(family)
[ "Trips", "Emergency Fund", "Demo Tag" ].each do |tag|
family.tags.create!(name: tag)
end
end
def create_categories!
categories = [ "Income", "Food & Drink", "Entertainment", "Travel",
"Personal Care", "General Services", "Auto & Transport",
"Rent & Utilities", "Home Improvement", "Shopping" ]
def create_categories!(family)
family.categories.bootstrap_defaults
categories.each do |category|
family.categories.create!(name: category, color: COLORS.sample)
end
food = family.categories.find_by(name: "Food & Drink")
family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, classification: "expense")
family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, classification: "expense")
family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, classification: "expense")
end
def create_merchants!
def create_merchants!(family)
merchants = [ "Amazon", "Starbucks", "McDonald's", "Target", "Costco",
"Home Depot", "Shell", "Whole Foods", "Walgreens", "Nike",
"Uber", "Netflix", "Spotify", "Delta Airlines", "Airbnb", "Sephora" ]
@@ -102,25 +128,25 @@ class Demo::Generator
end
end
def create_credit_card_account!
def create_credit_card_account!(family)
cc = family.accounts.create! \
accountable: CreditCard.new,
name: "Chase Credit Card",
balance: 2300,
currency: "USD"
50.times do
merchant = random_family_record(Merchant)
800.times do
merchant = random_family_record(Merchant, family)
create_transaction! \
account: cc,
name: merchant.name,
amount: Faker::Number.positive(to: 200),
tags: [ tag_for_merchant(merchant) ],
category: category_for_merchant(merchant),
tags: [ tag_for_merchant(merchant, family) ],
category: category_for_merchant(merchant, family),
merchant: merchant
end
5.times do
24.times do
create_transaction! \
account: cc,
amount: Faker::Number.negative(from: -1000),
@@ -128,30 +154,30 @@ class Demo::Generator
end
end
def create_checking_account!
def create_checking_account!(family)
checking = family.accounts.create! \
accountable: Depository.new,
name: "Chase Checking",
balance: 15000,
currency: "USD"
10.times do
200.times do
create_transaction! \
account: checking,
name: "Expense",
amount: Faker::Number.positive(from: 100, to: 1000)
end
10.times do
50.times do
create_transaction! \
account: checking,
amount: Faker::Number.negative(from: -2000),
name: "Income",
category: income_category
category: family.categories.find_by(name: "Income")
end
end
def create_savings_account!
def create_savings_account!(family)
savings = family.accounts.create! \
accountable: Depository.new,
name: "Demo Savings",
@@ -159,19 +185,50 @@ class Demo::Generator
currency: "USD",
subtype: "savings"
income_category = categories.find { |c| c.name == "Income" }
income_tag = tags.find { |t| t.name == "Emergency Fund" }
20.times do
100.times do
create_transaction! \
account: savings,
amount: Faker::Number.negative(from: -2000),
tags: [ income_tag ],
category: income_category,
tags: [ family.tags.find_by(name: "Emergency Fund") ],
category: family.categories.find_by(name: "Income"),
name: "Income"
end
end
def create_transfer_transactions!(family)
checking = family.accounts.find_by(name: "Chase Checking")
credit_card = family.accounts.find_by(name: "Chase Credit Card")
investment = family.accounts.find_by(name: "Robinhood")
create_transaction!(
account: checking,
date: 1.day.ago.to_date,
amount: 100,
name: "Credit Card Payment"
)
create_transaction!(
account: credit_card,
date: 1.day.ago.to_date,
amount: -100,
name: "Credit Card Payment"
)
create_transaction!(
account: checking,
date: 3.days.ago.to_date,
amount: 500,
name: "Transfer to investment"
)
create_transaction!(
account: investment,
date: 2.days.ago.to_date,
amount: -500,
name: "Transfer from checking"
)
end
def load_securities!
# Create an unknown security to simulate edge cases
Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock", exchange_mic: "UNKNOWN"
@@ -198,9 +255,7 @@ class Demo::Generator
end
end
def create_investment_account!
load_securities!
def create_investment_account!(family)
account = family.accounts.create! \
accountable: Investment.new,
name: "Robinhood",
@@ -238,7 +293,7 @@ class Demo::Generator
end
end
def create_house_and_mortgage!
def create_house_and_mortgage!(family)
house = family.accounts.create! \
accountable: Property.new,
name: "123 Maybe Way",
@@ -256,7 +311,7 @@ class Demo::Generator
currency: "USD"
end
def create_car_and_loan!
def create_car_and_loan!(family)
family.accounts.create! \
accountable: Vehicle.new,
name: "Honda Accord",
@@ -270,7 +325,7 @@ class Demo::Generator
currency: "USD"
end
def create_other_accounts!
def create_other_accounts!(family)
family.accounts.create! \
accountable: OtherAsset.new,
name: "Other Asset",
@@ -289,7 +344,7 @@ class Demo::Generator
transaction_attributes = attributes.slice(:category, :tags, :merchant)
entry_defaults = {
date: Faker::Number.between(from: 0, to: 90).days.ago.to_date,
date: Faker::Number.between(from: 0, to: 730).days.ago.to_date,
currency: "USD",
entryable: Account::Transaction.new(transaction_attributes)
}
@@ -303,69 +358,49 @@ class Demo::Generator
date: date,
amount: amount,
currency: "USD",
name: "Balance update",
entryable: Account::Valuation.new
end
def random_family_record(model)
def random_family_record(model, family)
family_records = model.where(family_id: family.id)
model.offset(rand(family_records.count)).first
end
def category_for_merchant(merchant)
def category_for_merchant(merchant, family)
mapping = {
"Amazon" => "Shopping",
"Starbucks" => "Food & Drink",
"McDonald's" => "Food & Drink",
"Target" => "Shopping",
"Costco" => "Food & Drink",
"Home Depot" => "Home Improvement",
"Shell" => "Auto & Transport",
"Home Depot" => "Housing",
"Shell" => "Transportation",
"Whole Foods" => "Food & Drink",
"Walgreens" => "Personal Care",
"Walgreens" => "Healthcare",
"Nike" => "Shopping",
"Uber" => "Auto & Transport",
"Netflix" => "Entertainment",
"Spotify" => "Entertainment",
"Delta Airlines" => "Travel",
"Airbnb" => "Travel",
"Sephora" => "Personal Care"
"Uber" => "Transportation",
"Netflix" => "Subscriptions",
"Spotify" => "Subscriptions",
"Delta Airlines" => "Transportation",
"Airbnb" => "Housing",
"Sephora" => "Shopping"
}
categories.find { |c| c.name == mapping[merchant.name] }
family.categories.find_by(name: mapping[merchant.name])
end
def tag_for_merchant(merchant)
def tag_for_merchant(merchant, family)
mapping = {
"Delta Airlines" => "Trips",
"Airbnb" => "Trips"
}
tag_from_merchant = tags.find { |t| t.name == mapping[merchant.name] }
tag_from_merchant || tags.find { |t| t.name == "Demo Tag" }
tag_from_merchant = family.tags.find_by(name: mapping[merchant.name])
tag_from_merchant || family.tags.find_by(name: "Demo Tag")
end
def securities
@securities ||= Security.all.to_a
end
def merchants
@merchants ||= family.merchants
end
def categories
@categories ||= family.categories
end
def tags
@tags ||= family.tags
end
def income_tag
@income_tag ||= tags.find { |t| t.name == "Emergency Fund" }
end
def income_category
@income_category ||= categories.find { |c| c.name == "Income" }
end
end

View File

@@ -1,7 +1,17 @@
class Family < ApplicationRecord
include Plaidable, Syncable
DATE_FORMATS = [ "%m-%d-%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%m/%d/%Y", "%e/%m/%Y", "%Y.%m.%d" ]
DATE_FORMATS = [
[ "MM-DD-YYYY", "%m-%d-%Y" ],
[ "DD.MM.YYYY", "%d.%m.%Y" ],
[ "DD-MM-YYYY", "%d-%m-%Y" ],
[ "YYYY-MM-DD", "%Y-%m-%d" ],
[ "DD/MM/YYYY", "%d/%m/%Y" ],
[ "YYYY/MM/DD", "%Y/%m/%d" ],
[ "MM/DD/YYYY", "%m/%d/%Y" ],
[ "D/MM/YYYY", "%e/%m/%Y" ],
[ "YYYY.MM.DD", "%Y.%m.%d" ]
].freeze
include Providable
@@ -17,9 +27,11 @@ class Family < ApplicationRecord
has_many :issues, through: :accounts
has_many :holdings, through: :accounts
has_many :plaid_items, dependent: :destroy
has_many :budgets, dependent: :destroy
has_many :budget_categories, through: :budgets
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :date_format, inclusion: { in: DATE_FORMATS }
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
def sync_data(start_date: nil)
update!(last_synced_at: Time.current)
@@ -42,20 +54,52 @@ class Family < ApplicationRecord
end
def syncing?
super || accounts.manual.any?(&:syncing?) || plaid_items.any?(&:syncing?)
Sync.where(
"(syncable_type = 'Family' AND syncable_id = ?) OR
(syncable_type = 'Account' AND syncable_id IN (SELECT id FROM accounts WHERE family_id = ? AND plaid_account_id IS NULL)) OR
(syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?))",
id, id, id
).where(status: [ "pending", "syncing" ]).exists?
end
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil)
return nil unless plaid_provider
def eu?
country != "US" && country != "CA"
end
plaid_provider.get_link_token(
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us)
provider = if region.to_sym == :eu
self.class.plaid_eu_provider
else
self.class.plaid_us_provider
end
# early return when no provider
return nil unless provider
provider.get_link_token(
user_id: id,
webhooks_url: webhooks_url,
redirect_url: redirect_url,
accountable_type: accountable_type
accountable_type: accountable_type,
).link_token
end
def income_categories_with_totals(date: Date.current)
categories_with_stats(classification: "income", date: date)
end
def expense_categories_with_totals(date: Date.current)
categories_with_stats(classification: "expense", date: date)
end
def category_stats
CategoryStats.new(self)
end
def budgeting_stats
BudgetingStats.new(self)
end
def snapshot(period = Period.all)
query = accounts.active.joins(:balances)
.where("account_balances.currency = ?", self.currency)
@@ -82,7 +126,9 @@ class Family < ApplicationRecord
def snapshot_account_transactions
period = Period.last_30_days
results = accounts.active.joins(:entries)
results = accounts.active
.joins(:entries)
.joins("LEFT JOIN transfers ON (transfers.inflow_transaction_id = account_entries.entryable_id OR transfers.outflow_transaction_id = account_entries.entryable_id)")
.select(
"accounts.*",
"COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending",
@@ -90,8 +136,8 @@ class Family < ApplicationRecord
)
.where("account_entries.date >= ?", period.date_range.begin)
.where("account_entries.date <= ?", period.date_range.end)
.where("account_entries.marked_as_transfer = ?", false)
.where("account_entries.entryable_type = ?", "Account::Transaction")
.where("account_entries.entryable_type = 'Account::Transaction'")
.where("transfers.id IS NULL")
.group("accounts.id")
.having("SUM(ABS(account_entries.amount)) > 0")
.to_a
@@ -110,9 +156,7 @@ class Family < ApplicationRecord
end
def snapshot_transactions
candidate_entries = entries.account_transactions.without_transfers.excluding(
entries.joins(:account).where(amount: ..0, accounts: { classification: Account.classifications[:liability] })
)
candidate_entries = entries.account_transactions.incomes_and_expenses
rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days)
spending = []
@@ -131,7 +175,7 @@ class Family < ApplicationRecord
savings << {
date: r.date,
value: r.rolling_income != 0 ? (r.rolling_income - r.rolling_spend) / r.rolling_income : 0.to_d
value: r.rolling_income != 0 ? ((r.rolling_income - r.rolling_spend) / r.rolling_income) : 0.to_d
}
end
@@ -173,4 +217,45 @@ class Family < ApplicationRecord
def primary_user
users.order(:created_at).first
end
def oldest_entry_date
entries.order(:date).first&.date || Date.current
end
def active_accounts_count
accounts.active.count
end
private
CategoriesWithTotals = Struct.new(:total_money, :category_totals, keyword_init: true)
CategoryWithStats = Struct.new(:category, :amount_money, :percentage, keyword_init: true)
def categories_with_stats(classification:, date: Date.current)
totals = category_stats.month_category_totals(date: date)
classified_totals = totals.category_totals.select { |t| t.classification == classification }
if classification == "income"
total = totals.total_income
categories_scope = categories.incomes
else
total = totals.total_expense
categories_scope = categories.expenses
end
categories_with_uncategorized = categories_scope + [ categories_scope.uncategorized ]
CategoriesWithTotals.new(
total_money: Money.new(total, currency),
category_totals: categories_with_uncategorized.map do |category|
ct = classified_totals.find { |ct| ct.category_id == category&.id }
CategoryWithStats.new(
category: category,
amount_money: Money.new(ct&.amount || 0, currency),
percentage: ct&.percentage || 0
)
end
)
end
end

View File

@@ -25,10 +25,15 @@ class Period
end
BUILTIN = [
new(name: "all", date_range: nil..Date.current),
new(name: "last_7_days", date_range: 7.days.ago.to_date..Date.current),
new(name: "last_30_days", date_range: 30.days.ago.to_date..Date.current),
new(name: "last_365_days", date_range: 365.days.ago.to_date..Date.current)
new(name: "all", date_range: nil..Date.current),
new(name: "current_week", date_range: Date.current.beginning_of_week..Date.current),
new(name: "last_7_days", date_range: 7.days.ago.to_date..Date.current),
new(name: "current_month", date_range: Date.current.beginning_of_month..Date.current),
new(name: "last_30_days", date_range: 30.days.ago.to_date..Date.current),
new(name: "current_quarter", date_range: Date.current.beginning_of_quarter..Date.current),
new(name: "last_90_days", date_range: 90.days.ago.to_date..Date.current),
new(name: "current_year", date_range: Date.current.beginning_of_year..Date.current),
new(name: "last_365_days", date_range: 365.days.ago.to_date..Date.current)
]
INDEX = BUILTIN.index_by(&:name)

View File

@@ -89,7 +89,6 @@ class PlaidAccount < ApplicationRecord
t.amount = plaid_txn.amount
t.currency = plaid_txn.iso_currency_code
t.date = plaid_txn.date
t.marked_as_transfer = transfer?(plaid_txn)
t.entryable = Account::Transaction.new(
category: get_category(plaid_txn.personal_finance_category.primary),
merchant: get_merchant(plaid_txn.merchant_name)

View File

@@ -31,7 +31,6 @@ class PlaidInvestmentSync
t.amount = transaction.amount
t.currency = transaction.iso_currency_code
t.date = transaction.date
t.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal])
t.entryable = Account::Transaction.new
end
else

View File

@@ -1,6 +1,8 @@
class PlaidItem < ApplicationRecord
include Plaidable, Syncable
enum :plaid_region, { us: "us", eu: "eu" }
if Rails.application.credentials.active_record_encryption.present?
encrypts :access_token, deterministic: true
end
@@ -19,13 +21,14 @@ class PlaidItem < ApplicationRecord
scope :ordered, -> { order(created_at: :desc) }
class << self
def create_from_public_token(token, item_name:)
response = plaid_provider.exchange_public_token(token)
def create_from_public_token(token, item_name:, region:)
response = plaid_provider_for_region(region).exchange_public_token(token)
new_plaid_item = create!(
name: item_name,
plaid_id: response.item_id,
access_token: response.access_token,
plaid_region: region
)
new_plaid_item.sync_later
@@ -131,5 +134,7 @@ class PlaidItem < ApplicationRecord
def remove_plaid_item
plaid_provider.remove_item(access_token)
rescue StandardError => e
Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}")
end
end

View File

@@ -6,7 +6,8 @@ class Property < ApplicationRecord
[ "Multi-Family Home", "multi_family_home" ],
[ "Condominium", "condominium" ],
[ "Townhouse", "townhouse" ],
[ "Investment Property", "investment_property" ]
[ "Investment Property", "investment_property" ],
[ "Second Home", "second_home" ]
]
has_one :address, as: :addressable, dependent: :destroy

View File

@@ -1,5 +1,5 @@
class Provider::Plaid
attr_reader :client
attr_reader :client, :region
MAYBE_SUPPORTED_PLAID_PRODUCTS = %w[transactions investments liabilities].freeze
MAX_HISTORY_DAYS = Rails.env.development? ? 90 : 730
@@ -54,18 +54,13 @@ class Provider::Plaid
actual_hash = Digest::SHA256.hexdigest(raw_body)
raise JWT::VerificationError, "Invalid webhook body hash" unless ActiveSupport::SecurityUtils.secure_compare(expected_hash, actual_hash)
end
def client
api_client = Plaid::ApiClient.new(
Rails.application.config.plaid
)
Plaid::PlaidApi.new(api_client)
end
end
def initialize
@client = self.class.client
def initialize(config, region)
@client = Plaid::PlaidApi.new(
Plaid::ApiClient.new(config)
)
@region = region
end
def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil)
@@ -74,7 +69,7 @@ class Provider::Plaid
client_name: "Maybe Finance",
products: [ get_primary_product(accountable_type) ],
additional_consented_products: get_additional_consented_products(accountable_type),
country_codes: [ "US" ],
country_codes: country_codes,
language: "en",
webhook: webhooks_url,
redirect_uri: redirect_url,
@@ -198,4 +193,12 @@ class Provider::Plaid
def get_additional_consented_products(accountable_type)
MAYBE_SUPPORTED_PLAID_PRODUCTS - [ get_primary_product(accountable_type) ]
end
def country_codes
if region.to_sym == :eu
[ "ES", "NL", "FR", "IE", "DE", "IT", "PL", "DK", "NO", "SE", "EE", "LT", "LV", "PT", "BE" ] # EU supported countries
else
[ "US", "CA" ] # US + CA only
end
end
end

View File

@@ -167,6 +167,35 @@ class Provider::Synth
raw_response: response
end
def enrich_transaction(description, amount: nil, date: nil, city: nil, state: nil, country: nil)
params = {
description: description,
amount: amount,
date: date,
city: city,
state: state,
country: country
}.compact
response = client.get("#{base_url}/enrich", params)
parsed = JSON.parse(response.body)
EnrichTransactionResponse.new \
info: EnrichTransactionInfo.new(
name: parsed.dig("merchant"),
icon_url: parsed.dig("icon"),
category: parsed.dig("category")
),
success?: true,
raw_response: response
rescue StandardError => error
EnrichTransactionResponse.new \
success?: false,
error: error,
raw_response: error
end
private
attr_reader :api_key
@@ -177,6 +206,8 @@ class Provider::Synth
UsageResponse = Struct.new :used, :limit, :utilization, :plan, :success?, :error, :raw_response, keyword_init: true
SearchSecuritiesResponse = Struct.new :securities, :success?, :error, :raw_response, keyword_init: true
SecurityInfoResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true
EnrichTransactionResponse = Struct.new :info, :success?, :error, :raw_response, keyword_init: true
EnrichTransactionInfo = Struct.new :name, :icon_url, :category, keyword_init: true
def base_url
ENV["SYNTH_URL"] || "https://api.synthfinance.com"

View File

@@ -0,0 +1,4 @@
class RejectedTransfer < ApplicationRecord
belongs_to :inflow_transaction, class_name: "Account::Transaction"
belongs_to :outflow_transaction, class_name: "Account::Transaction"
end

View File

@@ -20,4 +20,6 @@ class Setting < RailsSettings::Base
field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"]
field :require_invite_for_signup, type: :boolean, default: false
field :require_email_confirmation, type: :boolean, default: ENV.fetch("REQUIRE_EMAIL_CONFIRMATION", "true") == "true"
end

View File

@@ -34,6 +34,11 @@ class Sync < ApplicationRecord
scope.set_context("sync", { id: id })
end
update! status: :failed, error: error.message, last_ran_at: Time.current
update!(
status: :failed,
error: error.message,
error_backtrace: error.backtrace&.first(10),
last_ran_at: Time.current
)
end
end

View File

@@ -37,6 +37,14 @@ class TimeSeries
series: self
end
def empty?
values.empty?
end
def has_current_day_value?
values.any? { |v| v.date == Date.current }
end
# `as_json` returns the data shape used by D3 charts
def as_json
{

129
app/models/transfer.rb Normal file
View File

@@ -0,0 +1,129 @@
class Transfer < ApplicationRecord
belongs_to :inflow_transaction, class_name: "Account::Transaction"
belongs_to :outflow_transaction, class_name: "Account::Transaction"
enum :status, { pending: "pending", confirmed: "confirmed" }
validates :inflow_transaction_id, uniqueness: true
validates :outflow_transaction_id, uniqueness: true
validate :transfer_has_different_accounts
validate :transfer_has_opposite_amounts
validate :transfer_within_date_range
validate :transfer_has_same_family
class << self
def from_accounts(from_account:, to_account:, date:, amount:)
# Attempt to convert the amount to the to_account's currency.
# If the conversion fails, use the original amount.
converted_amount = begin
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
rescue Money::ConversionError
Money.new(amount.abs, from_account.currency)
end
new(
inflow_transaction: Account::Transaction.new(
entry: to_account.entries.build(
amount: converted_amount.amount.abs * -1,
currency: converted_amount.currency.iso_code,
date: date,
name: "Transfer from #{from_account.name}",
entryable: Account::Transaction.new
)
),
outflow_transaction: Account::Transaction.new(
entry: from_account.entries.build(
amount: amount.abs,
currency: from_account.currency,
date: date,
name: "Transfer to #{to_account.name}",
entryable: Account::Transaction.new
)
),
status: "confirmed"
)
end
end
def reject!
Transfer.transaction do
RejectedTransfer.find_or_create_by!(inflow_transaction_id: inflow_transaction_id, outflow_transaction_id: outflow_transaction_id)
destroy!
end
end
def confirm!
update!(status: "confirmed")
end
def sync_account_later
inflow_transaction.entry.sync_account_later
outflow_transaction.entry.sync_account_later
end
def belongs_to_family?(family)
family.transactions.include?(inflow_transaction)
end
def to_account
inflow_transaction.entry.account
end
def from_account
outflow_transaction.entry.account
end
def amount_abs
inflow_transaction.entry.amount_money.abs
end
def name
if payment?
I18n.t("transfer.payment_name", to_account: to_account.name)
else
I18n.t("transfer.name", to_account: to_account.name)
end
end
def payment?
to_account.liability?
end
def categorizable?
to_account.accountable_type == "Loan"
end
private
def transfer_has_different_accounts
return unless inflow_transaction.present? && outflow_transaction.present?
errors.add(:base, :must_be_from_different_accounts) if inflow_transaction.entry.account == outflow_transaction.entry.account
end
def transfer_has_same_family
return unless inflow_transaction.present? && outflow_transaction.present?
errors.add(:base, :must_be_from_same_family) unless inflow_transaction.entry.account.family == outflow_transaction.entry.account.family
end
def transfer_has_opposite_amounts
return unless inflow_transaction.present? && outflow_transaction.present?
inflow_amount = inflow_transaction.entry.amount
outflow_amount = outflow_transaction.entry.amount
if inflow_transaction.entry.currency == outflow_transaction.entry.currency
# For same currency, amounts must be exactly opposite
errors.add(:base, :must_have_opposite_amounts) if inflow_amount + outflow_amount != 0
else
# For different currencies, just check the signs are opposite
errors.add(:base, :must_have_opposite_amounts) unless inflow_amount.negative? && outflow_amount.positive?
end
end
def transfer_within_date_range
return unless inflow_transaction.present? && outflow_transaction.present?
date_diff = (inflow_transaction.entry.date - outflow_transaction.entry.date).abs
errors.add(:base, :must_be_within_date_range) if date_diff > 4
end
end

View File

@@ -7,16 +7,18 @@ class User < ApplicationRecord
has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy
accepts_nested_attributes_for :family, update_only: true
validates :email, presence: true, uniqueness: true
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validate :ensure_valid_profile_image
normalizes :email, with: ->(email) { email.strip.downcase }
normalizes :unconfirmed_email, with: ->(email) { email&.strip&.downcase }
normalizes :first_name, :last_name, with: ->(value) { value.strip.presence }
enum :role, { member: "member", admin: "admin", super_admin: "super_admin" }, validate: true
has_one_attached :profile_image do |attachable|
attachable.variant :thumbnail, resize_to_fill: [ 300, 300 ]
attachable.variant :thumbnail, resize_to_fill: [ 300, 300 ], convert: :webp, saver: { quality: 80 }
attachable.variant :small, resize_to_fill: [ 72, 72 ], convert: :webp, saver: { quality: 80 }
end
validate :profile_image_size
@@ -25,6 +27,30 @@ class User < ApplicationRecord
password_salt&.last(10)
end
generates_token_for :email_confirmation, expires_in: 1.day do
unconfirmed_email
end
def pending_email_change?
unconfirmed_email.present?
end
def initiate_email_change(new_email)
return false if new_email == email
return false if new_email == unconfirmed_email
if Rails.application.config.app_mode.self_hosted? && !Setting.require_email_confirmation
update(email: new_email)
else
if update(unconfirmed_email: new_email)
EmailConfirmationMailer.with(user: self).confirmation_email.deliver_later
true
else
false
end
end
end
def request_impersonation_for(user_id)
impersonated = User.find(user_id)
impersonator_support_sessions.create!(impersonated: impersonated)

View File

@@ -1,93 +0,0 @@
<%= turbo_frame_tag dom_id(@account, "entries") do %>
<div class="bg-white p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex items-center justify-between mb-4">
<%= tag.h2 t(".title"), class: "font-medium text-lg" %>
<% unless @account.plaid_account_id.present? %>
<div data-controller="menu" data-testid="activity-menu">
<button class="btn btn--secondary flex items-center gap-2" data-menu-target="button">
<%= lucide_icon("plus", class: "w-4 h-4") %>
<%= tag.span t(".new") %>
</button>
<div data-menu-target="content" class="z-10 hidden bg-white rounded-lg border border-alpha-black-25 shadow-xs p-1">
<%= link_to new_account_valuation_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= lucide_icon("circle-dollar-sign", class: "text-gray-500 w-5 h-5") %>
<%= tag.span t(".new_balance"), class: "text-sm" %>
<% end %>
<% unless @account.crypto? %>
<%= link_to @account.investment? ? new_account_trade_path(account_id: @account.id) : new_account_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= lucide_icon("credit-card", class: "text-gray-500 w-5 h-5") %>
<%= tag.span t(".new_transaction"), class: "text-sm" %>
<% end %>
<% end %>
</div>
</div>
<% end %>
</div>
<div>
<%= form_with url: account_entries_path,
id: "entries-search",
scope: :q,
method: :get,
data: { controller: "auto-submit-form" } do |form| %>
<div class="flex gap-2 mb-4">
<div class="grow">
<div class="flex items-center px-3 py-2 gap-2 border border-gray-200 rounded-lg focus-within:ring-gray-100 focus-within:border-gray-900">
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500") %>
<%= hidden_field_tag :account_id, @account.id %>
<%= form.search_field :search,
placeholder: "Search entries by name",
value: @q[:search],
class: "form-field__input placeholder:text-sm placeholder:text-gray-500",
"data-auto-submit-form-target": "auto" %>
</div>
</div>
</div>
<% end %>
</div>
<% if @entries.empty? %>
<p class="text-gray-500 text-sm p-4"><%= t(".no_entries") %></p>
<% else %>
<%= tag.div id: dom_id(@account, "entries_bulk_select"),
data: {
controller: "bulk-select",
bulk_select_singular_label_value: t(".entry"),
bulk_select_plural_label_value: t(".entries")
} do %>
<div id="entry-selection-bar" data-bulk-select-target="selectionBar" class="flex justify-center hidden">
<%= render "account/entries/selection_bar" %>
</div>
<div class="grid bg-gray-25 rounded-xl grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-5 py-3 mb-4">
<div class="pl-0.5 col-span-8 flex items-center gap-4">
<%= check_box_tag "selection_entry",
class: "maybe-checkbox maybe-checkbox--light",
data: { action: "bulk-select#togglePageSelection" } %>
<p><%= t(".date") %></p>
</div>
<%= tag.p t(".amount"), class: "col-span-2 justify-self-end" %>
<%= tag.p t(".balance"), class: "col-span-2 justify-self-end" %>
</div>
<div>
<div class="rounded-tl-lg rounded-tr-lg bg-white border-alpha-black-25 shadow-xs">
<div class="space-y-4">
<% calculator = Account::BalanceTrendCalculator.for(@entries) %>
<%= entries_by_date(@entries) do |entries| %>
<% entries.each do |entry| %>
<%= render entry, balance_trend: calculator&.trend_for(entry) %>
<% end %>
<% end %>
</div>
</div>
<div class="p-4 bg-white rounded-bl-lg rounded-br-lg">
<%= render "pagination", pagy: @pagy %>
</div>
</div>
<% end %>
<% end %>
</div>
<% end %>

View File

@@ -3,7 +3,7 @@
<%= turbo_frame_tag dom_id(holding) do %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="col-span-4 flex items-center gap-4">
<%= image_tag "https://logo.synthfinance.com/ticker/#{holding.ticker}", class: "w-9 h-9 rounded-full" %>
<%= image_tag "https://logo.synthfinance.com/ticker/#{holding.ticker}", class: "w-9 h-9 rounded-full", loading: "lazy" %>
<div class="space-y-0.5">
<%= link_to holding.name, account_holding_path(holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>

View File

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

View File

@@ -22,7 +22,7 @@
{ label: t(".type"), selected: type },
{ data: {
action: "trade-form#changeType",
trade_form_url_param: new_account_trade_path(account_id: entry.account_id),
trade_form_url_param: new_account_trade_path(account_id: entry.account&.id || entry.account_id),
trade_form_key_param: "type",
}} %>

View File

@@ -34,7 +34,7 @@
<dd class="text-gray-900"><%= trade.security.ticker %></dd>
</div>
<% if trade.buy? %>
<% if trade.qty.positive? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".purchase_qty_label") %></dt>
<dd class="text-gray-900"><%= trade.qty.abs %></dd>
@@ -53,7 +53,7 @@
</div>
<% end %>
<% if trade.buy? && trade.unrealized_gain_loss.present? %>
<% if trade.qty.positive? && trade.unrealized_gain_loss.present? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".total_return_label") %></dt>
<dd style="color: <%= trade.unrealized_gain_loss.color %>;">

View File

@@ -3,7 +3,7 @@
<% trade, account = entry.account_trade, entry.account %>
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
<div class="col-span-8 flex items-center gap-4">
<div class="col-span-6 flex items-center gap-4">
<% if selectable %>
<%= check_box_tag dom_id(entry, "selection"),
class: "maybe-checkbox maybe-checkbox--light",
@@ -13,14 +13,14 @@
<div class="max-w-full">
<%= tag.div class: ["flex items-center gap-2"] do %>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
<%= trade.name.first.upcase %>
<%= entry.display_name.first.upcase %>
</div>
<div class="truncate">
<% if entry.new_record? %>
<%= content_tag :p, trade.name %>
<%= content_tag :p, entry.display_name %>
<% else %>
<%= link_to trade.name,
<%= link_to entry.display_name,
account_entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
@@ -30,6 +30,10 @@
</div>
</div>
<div class="col-span-2 flex items-center">
<%= render "categories/badge", category: trade_category %>
</div>
<div class="col-span-2 justify-self-end font-medium text-sm">
<%= content_tag :p,
format_money(-entry.amount_money),

View File

@@ -32,7 +32,7 @@
<p class="text-gray-500 py-4"><%= t(".no_trades") %></p>
<% else %>
<div class="space-y-6">
<%= entries_by_date(@entries) do |entries| %>
<%= entries_by_date(@entries) do |entries, _transfers| %>
<%= render partial: "account/trades/trade", collection: entries, as: :entry %>
<% end %>
</div>

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