Compare commits

...

48 Commits

Author SHA1 Message Date
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
161 changed files with 3918 additions and 1160 deletions

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,8 +83,8 @@ 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.1031.0)
aws-sdk-core (3.214.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -92,7 +92,7 @@ GEM
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.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)
@@ -108,11 +108,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)
@@ -127,40 +127,40 @@ GEM
childprocess (5.0.0)
climate_control (1.2.0)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
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)
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.8.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)
@@ -176,7 +176,7 @@ GEM
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (4.5.1)
good_job (4.7.0)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
@@ -185,10 +185,10 @@ GEM
thor (>= 1.0.0)
hashdiff (1.1.1)
highline (3.0.1)
hotwire-livereload (1.4.1)
actioncable (>= 6.0.0)
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)
@@ -208,22 +208,23 @@ 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.5)
activesupport (> 4.0)
jwt (~> 2.0)
io-console (0.8.0)
irb (1.14.1)
irb (1.14.3)
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)
@@ -234,8 +235,8 @@ GEM
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)
@@ -247,14 +248,15 @@ GEM
matrix (0.4.2)
mini_magick (4.13.2)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
minitest (5.25.4)
mocha (2.7.0)
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
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.1)
date
net-protocol
net-pop (0.1.2)
@@ -264,37 +266,38 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.4)
nokogiri (1.17.0-aarch64-linux)
nokogiri (1.18.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.17.0-arm-linux)
nokogiri (1.18.1-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.17.0-arm64-darwin)
nokogiri (1.18.1-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.17.0-x86-linux)
nokogiri (1.18.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.17.0-x86_64-darwin)
nokogiri (1.18.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.17.0-x86_64-linux)
nokogiri (1.18.1-x86_64-linux-gnu)
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.6.0)
ast (~> 2.4.1)
racc
pg (1.5.9)
plaid (34.0.0)
faraday (>= 1.0.1, < 3.0)
faraday-multipart (>= 1.0.1, < 2.0)
prism (1.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.2)
date
stringio
public_suffix (6.0.1)
@@ -303,42 +306,43 @@ GEM
raabro (1.4.0)
racc (1.8.1)
rack (3.1.8)
rack-session (2.0.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)
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,26 +353,26 @@ 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.10.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)
rubocop (1.70.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.37.0)
parser (>= 3.3.1.0)
rubocop-minitest (0.35.0)
rubocop (>= 1.61, < 2.0)
@@ -386,13 +390,13 @@ GEM
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.22.1)
ruby-lsp (0.23.5)
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.29)
ruby-lsp (>= 0.23.0, < 0.24.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.2)
ffi (~> 1.12)
@@ -402,17 +406,17 @@ GEM
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
securerandom (0.4.0)
securerandom (0.4.1)
selenium-webdriver (4.27.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.1)
railties (>= 5.0)
sentry-ruby (~> 5.22.0)
sentry-ruby (5.22.0)
sentry-ruby (~> 5.22.1)
sentry-ruby (5.22.1)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
simplecov (0.22.0)
@@ -422,25 +426,25 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11663)
sorbet-runtime (0.5.11751)
stackprof (0.2.26)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.2)
stripe (13.2.0)
tailwindcss-rails (3.0.0)
stripe (13.3.0)
tailwindcss-rails (3.2.0)
railties (>= 7.0.0)
tailwindcss-ruby
tailwindcss-ruby (3.4.14)
tailwindcss-ruby (3.4.14-aarch64-linux)
tailwindcss-ruby (3.4.14-arm-linux)
tailwindcss-ruby (3.4.14-arm64-darwin)
tailwindcss-ruby (3.4.14-x86_64-darwin)
tailwindcss-ruby (3.4.14-x86_64-linux)
tailwindcss-ruby (3.4.17)
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 (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
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)

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,10 +56,18 @@
@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");
}

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

@@ -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

@@ -10,6 +10,7 @@ class CategoriesController < ApplicationController
def new
@category = Current.family.categories.new color: Category::COLORS.sample
@categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id)
end
def create
@@ -17,19 +18,28 @@ 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)
@categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id)
render :new, status: :unprocessable_entity
end
end
def edit
@categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id)
end
def update
@category.update! category_params
redirect_back_or_to transactions_path, notice: t(".success")
redirect_back_or_to categories_path, notice: t(".success")
end
def destroy
@@ -38,6 +48,12 @@ 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])
@@ -50,6 +66,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

@@ -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

@@ -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

@@ -3,13 +3,18 @@ class TransactionsController < ApplicationController
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).reverse_chronological
@pagy, @transaction_entries = pagy(search_query, limit: params[:per_page] || "50")
totals_query = search_query.incomes_and_expenses
family_currency = Current.family.currency
count_with_transfers = search_query.count
count_without_transfers = totals_query.count
@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)
count: ((count_with_transfers - count_without_transfers) / 2) + count_without_transfers,
income: totals_query.income_total(family_currency).abs,
expense: totals_query.expense_total(family_currency)
}
end

View File

@@ -0,0 +1,71 @@
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
Transfer.transaction do
@transfer.update!(transfer_update_params.except(:category_id))
@transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id])
end
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

@@ -41,7 +41,7 @@ class UsersController < ApplicationController
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 ]
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ]
)
end

View File

@@ -3,10 +3,6 @@ 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
@@ -18,8 +14,19 @@ module Account::EntriesHelper
yield grouped_entries
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

@@ -4,7 +4,7 @@ module ApplicationHelper
def date_format_options
[
[ "DD-MM-YYYY", "%d-%m-%Y" ],
[ "DD.MM.YY", "%d.%m.%Y" ],
[ "DD.MM.YYYY", "%d.%m.%Y" ],
[ "MM-DD-YYYY", "%m-%d-%Y" ],
[ "YYYY-MM-DD", "%Y-%m-%d" ],
[ "DD/MM/YYYY", "%d/%m/%Y" ],
@@ -15,6 +15,16 @@ module ApplicationHelper
]
end
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)
content_for(:title) { page_title }
end
@@ -67,9 +77,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 = {})
@@ -166,4 +176,24 @@ module ApplicationHelper
cookies[:admin] == "true"
end
def custom_pagy_url_for(pagy, page, current_path: nil)
if current_path.blank?
pagy_url_for(pagy, page)
else
uri = URI.parse(current_path)
params = URI.decode_www_form(uri.query || "").to_h
# Delete existing page param if it exists
params.delete("page")
# Add new page param unless it's page 1
params["page"] = page unless page == 1
if params.empty?
uri.path
else
"#{uri.path}?#{URI.encode_www_form(params)}"
end
end
end
end

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,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

@@ -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,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

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

View File

@@ -126,6 +126,14 @@ 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)
transaction do
update!(attributes)
@@ -143,6 +151,7 @@ class Account < ApplicationRecord
else
entries.create! \
date: Date.current,
name: "Balance update",
amount: balance,
currency: currency,
entryable: Account::Valuation.new

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

@@ -10,14 +10,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 +25,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 +64,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 +72,24 @@ class Account::Entry < ApplicationRecord
Account::BalanceTrendCalculator.new(self, entries, balances).trend
end
def display_name
enriched_name.presence || name
end
def transfer_match_candidates
account.family.entries
.where.not(account_id: account_id)
.where.not(id: id)
.where(amount: -amount)
.where(currency: currency)
.where(date: (date - 4.days)..(date + 4.days))
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 +124,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 +146,20 @@ class Account::Entry < ApplicationRecord
all.size
end
def income_total(currency = "USD")
total = without_transfers.account_transactions.includes(:entryable)
.where("account_entries.amount <= 0")
def income_total(currency = "USD", start_date: nil, end_date: nil)
total = incomes.where(date: start_date..end_date)
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum
Money.new(total, currency)
end
def expense_total(currency = "USD")
total = without_transfers.account_transactions.includes(:entryable)
.where("account_entries.amount > 0")
def expense_total(currency = "USD", start_date: nil, end_date: nil)
total = expenses.where(date: start_date..end_date)
.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

@@ -98,7 +98,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

View File

@@ -5,11 +5,20 @@ class Account::Syncer
end
def run
Transfer.auto_match_for_account(account)
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

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,24 @@ 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: :restrict_with_exception
has_one :transfer_as_outflow, class_name: "Transfer", foreign_key: "outflow_transaction_id", dependent: :restrict_with_exception
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? && transfer.status != "rejected"
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.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
budget_categories.reject(&:subcategory?).sum(&:actual_spending)
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,87 @@
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
scope :alphabetically, -> { order(:name) }
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 replace_and_destroy!(replacement)
@@ -47,9 +91,32 @@ class Category < ApplicationRecord
end
end
private
def subcategory?
parent.present?
end
def clear_internal_category
self.internal_category = nil
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
private
def category_level_limit
if 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

@@ -36,6 +36,8 @@ class Demo::Generator
create_car_and_loan!
create_other_accounts!
create_transfer_transactions!
puts "accounts created"
puts "Demo data loaded successfully!"
end
@@ -49,12 +51,14 @@ class Demo::Generator
family_id = "d99e3c6e-d513-4452-8f24-dc263f8528c0" # deterministic demo id
family = Family.find_by(id: family_id)
Transfer.destroy_all
family.destroy! if family
Family.create!(id: family_id, name: "Demo Family", stripe_subscription_status: "active").tap(&:reload)
end
def clear_data!
Transfer.destroy_all
InviteCode.destroy_all
User.find_by_email("user@maybe.local")&.destroy
ExchangeRate.destroy_all
@@ -83,13 +87,12 @@ class Demo::Generator
end
def create_categories!
categories = [ "Income", "Food & Drink", "Entertainment", "Travel",
"Personal Care", "General Services", "Auto & Transport",
"Rent & Utilities", "Home Improvement", "Shopping" ]
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!
@@ -172,6 +175,40 @@ class Demo::Generator
end
end
def create_transfer_transactions!
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"
@@ -303,6 +340,7 @@ class Demo::Generator
date: date,
amount: amount,
currency: "USD",
name: "Balance update",
entryable: Account::Valuation.new
end
@@ -318,17 +356,17 @@ class Demo::Generator
"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] }

View File

@@ -1,7 +1,7 @@
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 = [ "%m-%d-%Y", "%d.%m.%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%m/%d/%Y", "%e/%m/%Y", "%Y.%m.%d" ]
include Providable
@@ -17,6 +17,8 @@ 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 }
@@ -56,6 +58,22 @@ class Family < ApplicationRecord
).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 +100,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 +110,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 +130,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 +149,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 +191,41 @@ class Family < ApplicationRecord
def primary_user
users.order(:created_at).first
end
def oldest_entry_date
entries.order(:date).first&.date || Date.current
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

@@ -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

@@ -74,7 +74,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: [ "US", "CA" ],
language: "en",
webhook: webhooks_url,
redirect_uri: redirect_url,

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

@@ -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

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

@@ -0,0 +1,158 @@
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", rejected: "rejected" }
validate :transfer_has_different_accounts
validate :transfer_has_opposite_amounts
validate :transfer_within_date_range
validate :transfer_has_same_family
validate :inflow_on_or_after_outflow
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
def auto_match_for_account(account)
matches = Account::Entry.select([
"inflow_candidates.entryable_id as inflow_transaction_id",
"outflow_candidates.entryable_id as outflow_transaction_id"
]).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 AND
inflow_candidates.date >= outflow_candidates.date
)
").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("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 = ?", account.family_id, account.family_id)
.where("inflow_candidates.entryable_type = 'Account::Transaction' AND outflow_candidates.entryable_type = 'Account::Transaction'")
.where(existing_transfers: { id: nil })
Transfer.transaction do
matches.each do |match|
Transfer.create!(
inflow_transaction_id: match.inflow_transaction_id,
outflow_transaction_id: match.outflow_transaction_id,
)
end
end
end
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 inflow_on_or_after_outflow
return unless inflow_transaction.present? && outflow_transaction.present?
errors.add(:base, :inflow_must_be_on_or_after_outflow) if inflow_transaction.entry.date < outflow_transaction.entry.date
end
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

@@ -84,7 +84,7 @@
</div>
<div class="p-4 bg-white rounded-bl-lg rounded-br-lg">
<%= render "pagination", pagy: @pagy %>
<%= render "pagination", pagy: @pagy, current_path: account_path(@account, page: params[:page], tab: params[:tab]) %>
</div>
</div>
<% end %>

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

@@ -8,7 +8,7 @@
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
<%= radio_tab_tag form: f, name: :nature, value: :outflow, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "outflow" || params[:nature].nil? %>
<%= radio_tab_tag form: f, name: :nature, value: :inflow, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "inflow" %>
<%= link_to new_account_transfer_path, data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm" do %>
<%= link_to new_transfer_path, data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm" do %>
<%= lucide_icon "arrow-right-left", class: "w-5 h-5" %>
<%= tag.span t(".transfer") %>
<% end %>

View File

@@ -12,7 +12,7 @@
</span>
</h3>
<% if entry.marked_as_transfer? %>
<% if entry.account_transaction.transfer? %>
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
<% end %>
</div>

View File

@@ -8,26 +8,6 @@
<div class="flex items-center gap-1 text-gray-500">
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
<%= form_with url: mark_transfers_account_transactions_path,
scope: "bulk_update",
data: {
turbo_frame: "_top",
turbo_confirm: {
title: t(".mark_transfers"),
body: t(".mark_transfers_message"),
accept: t(".mark_transfers_confirm"),
}
} do |f| %>
<button id="bulk-transfer-btn"
type="button"
data-bulk-select-scope-param="bulk_update"
data-action="bulk-select#submitBulkRequest"
class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md"
title="Mark as transfer">
<%= lucide_icon "arrow-right-left", class: "w-5 group-hover:text-white" %>
</button>
<% end %>
<%= link_to bulk_edit_account_transactions_path,
class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md",
title: "Edit",

View File

@@ -1,69 +1,67 @@
<%# locals: (entry:, selectable: true, balance_trend: nil) %>
<% transaction, account = entry.account_transaction, 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="pr-10 flex items-center gap-4 col-span-6">
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="pr-10 flex items-center gap-4 <%= balance_trend ? "col-span-6" : "col-span-8" %>">
<% if selectable %>
<%= check_box_tag dom_id(entry, "selection"),
disabled: entry.account_transaction.transfer?,
class: "maybe-checkbox maybe-checkbox--light",
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
<% end %>
<div class="max-w-full">
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
<%= transaction.name.first.upcase %>
</div>
<% if transaction.merchant&.icon_url %>
<%= image_tag transaction.merchant.icon_url, class: "w-6 h-6 rounded-full" %>
<% else %>
<%= render "shared/circle_logo", name: entry.display_name, size: "sm" %>
<% end %>
<div class="truncate">
<% if entry.new_record? %>
<%= content_tag :p, transaction.name %>
<% else %>
<%= link_to transaction.name,
entry.transfer.present? ? account_transfer_path(entry.transfer) : account_entry_path(entry),
<div class="space-y-0.5">
<div class="flex items-center gap-1">
<% if entry.new_record? %>
<%= content_tag :p, entry.display_name %>
<% else %>
<%= link_to entry.account_transaction.transfer? ? entry.account_transaction.transfer.name : entry.display_name,
entry.account_transaction.transfer? ? transfer_path(entry.account_transaction.transfer) : account_entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
<% end %>
<% end %>
<% if entry.excluded %>
<span title="One-time <%= entry.amount.negative? ? "income" : "expense" %> (excluded from averages)">
<%= lucide_icon "asterisk", class: "w-4 h-4 shrink-0 text-orange-500" %>
</span>
<% end %>
<% if entry.account_transaction.transfer? %>
<%= render "account/transactions/transfer_match", entry: entry %>
<% end %>
</div>
<div class="text-gray-500 text-xs font-normal">
<% if entry.account_transaction.transfer? %>
<%= render "transfers/account_links", transfer: entry.account_transaction.transfer, is_inflow: entry.account_transaction.transfer_as_inflow.present? %>
<% else %>
<%= link_to entry.account.name, account_path(entry.account, tab: "transactions"), data: { turbo_frame: "_top" }, class: "hover:underline" %>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
<% if unconfirmed_transfer?(entry) %>
<%= render "account/transfers/transfer_toggle", entry: entry %>
<% end %>
</div>
<% if entry.transfer.present? %>
<% unless balance_trend %>
<div class="col-span-2"></div>
<% end %>
<div class="col-span-2">
<%= render "account/transfers/account_logos", transfer: entry.transfer, outflow: entry.outflow? %>
</div>
<% else %>
<div class="flex items-center gap-1 col-span-2">
<%= render "categories/menu", transaction: transaction %>
</div>
<% unless balance_trend %>
<%= tag.div class: "col-span-2 overflow-hidden truncate" do %>
<% if entry.new_record? %>
<%= tag.p account.name %>
<% else %>
<%= link_to account.name,
account_path(account, tab: "transactions"),
data: { turbo_frame: "_top" },
class: "hover:underline" %>
<% end %>
<% end %>
<% end %>
<% end %>
<div class="flex items-center gap-1 col-span-2">
<%= render "account/transactions/transaction_category", entry: entry %>
</div>
<div class="col-span-2 ml-auto">
<%= content_tag :p,
format_money(-entry.amount_money),
class: ["text-green-600": entry.inflow?] %>
class: ["text-green-600": entry.amount.negative?] %>
</div>
<% if balance_trend %>

View File

@@ -0,0 +1,9 @@
<%# locals: (entry:) %>
<div id="<%= dom_id(entry, "category_menu") %>">
<% if entry.account_transaction.transfer&.categorizable? || entry.account_transaction.transfer.nil? || entry.account_transaction.transfer&.rejected? %>
<%= render "categories/menu", transaction: entry.account_transaction %>
<% else %>
<%= render "categories/badge", category: entry.account_transaction.transfer&.payment? ? payment_category : transfer_category %>
<% end %>
</div>

View File

@@ -0,0 +1,27 @@
<%# locals: (entry:) %>
<div id="<%= dom_id(entry, "transfer_match") %>" class="flex items-center gap-1">
<% if entry.account_transaction.transfer.confirmed? %>
<span title="<%= entry.account_transaction.transfer.payment? ? "Payment" : "Transfer" %> is confirmed">
<%= lucide_icon "link-2", class: "w-4 h-4 text-indigo-600" %>
</span>
<% elsif entry.account_transaction.transfer.pending? %>
<span class="inline-flex items-center rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">
Auto-matched
</span>
<%= button_to transfer_path(entry.account_transaction.transfer, transfer: { status: "confirmed" }),
method: :patch,
class: "text-gray-500 hover:text-gray-800 flex items-center justify-center",
title: "Confirm match" do %>
<%= lucide_icon "check", class: "w-4 h-4 text-indigo-400 hover:text-indigo-600" %>
<% end %>
<%= button_to transfer_path(entry.account_transaction.transfer, transfer: { status: "rejected" }),
method: :patch,
class: "text-gray-500 hover:text-gray-800 flex items-center justify-center",
title: "Reject match" do %>
<%= lucide_icon "x", class: "w-4 h-4 text-gray-400 hover:text-gray-600" %>
<% end %>
<% end %>
</div>

View File

@@ -9,7 +9,8 @@
url: account_transaction_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.text_field :name,
<%= f.text_field @entry.enriched_at.present? ? :enriched_name : :name,
label: t(".name_label"),
"data-auto-submit-form-target": "auto" %>
@@ -18,7 +19,7 @@
max: Date.current,
"data-auto-submit-form-target": "auto" %>
<% unless @entry.marked_as_transfer? %>
<% unless @entry.account_transaction.transfer? %>
<div class="flex items-center gap-2">
<%= f.select :nature,
[["Expense", "outflow"], ["Income", "inflow"]],
@@ -31,27 +32,7 @@
min: 0,
value: @entry.amount.abs %>
</div>
<% end %>
<%= f.select :account,
options_for_select(
Current.family.accounts.alphabetically.pluck(:name, :id),
@entry.account_id
),
{ label: t(".account_label") },
{ disabled: true } %>
<% end %>
</div>
<% end %>
<!-- Details Section -->
<%= disclosure t(".details") do %>
<div class="pb-4">
<%= styled_form_with model: @entry,
url: account_transaction_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<% unless @entry.marked_as_transfer? %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :category_id,
Current.family.categories.alphabetically,
@@ -59,6 +40,30 @@
{ label: t(".category_label"),
class: "text-gray-400", include_blank: t(".uncategorized") },
"data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
<% end %>
</div>
<% end %>
<!-- Details Section -->
<%= disclosure t(".details"), default_open: false do %>
<div class="pb-4">
<%= styled_form_with model: @entry,
url: account_transaction_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<% unless @entry.account_transaction.transfer? %>
<%= f.select :account,
options_for_select(
Current.family.accounts.alphabetically.pluck(:name, :id),
@entry.account_id
),
{ label: t(".account_label") },
{ disabled: true } %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :merchant_id,
Current.family.merchants.alphabetically,
@@ -93,15 +98,15 @@
<!-- Settings Section -->
<%= disclosure t(".settings") do %>
<div class="pb-4">
<!-- Exclude Transaction Form -->
<%= styled_form_with model: @entry,
url: account_transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<div class="flex cursor-pointer items-center gap-2 justify-between">
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
<h4 class="text-gray-900">One-time <%= @entry.amount.negative? ? "Income" : "Expense" %></h4>
<p class="text-gray-500">One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.</p>
</div>
<div class="relative inline-block select-none">
@@ -114,6 +119,18 @@
</div>
<% end %>
<div class="flex items-center justify-between gap-4 p-3">
<div class="text-sm space-y-1">
<h4 class="text-gray-900">Transfer or Debt Payment?</h4>
<p class="text-gray-500">Transfers and payments are special types of transactions that indicate money movement between 2 accounts.</p>
</div>
<%= link_to new_account_transaction_transfer_match_path(@entry), class: "btn btn--outline flex items-center gap-2", data: { turbo_frame: :modal } do %>
<%= lucide_icon "arrow-left-right", class: "w-4 h-4 shrink-0" %>
<span class="whitespace-nowrap">Open matcher</span>
<% end %>
</div>
<!-- Delete Transaction Form -->
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">

View File

@@ -0,0 +1,44 @@
<%# locals: (form:, entry:, candidates:, accounts:) %>
<% if candidates.any? %>
<div data-controller="transfer-match" class="space-y-2">
<p class="text-sm text-gray-500">
Select a method for matching your transactions.
</p>
<%= form.select :method,
[
["Match existing transaction (recommended)", "existing"],
["Create new transaction", "new"]
],
{ selected: "existing", label: "Matching method" },
data: { action: "change->transfer-match#update" } %>
<div data-transfer-match-target="existingSelect">
<%= form.select :matched_entry_id,
candidates.map { |entry|
[entry_name_detailed(entry), entry.id]
},
{ label: "Matching transaction" } %>
</div>
<div data-transfer-match-target="newSelect" class="hidden">
<%= form.select :target_account_id,
accounts.map { |account| [account.name, account.id] },
{ label: "Target account" } %>
</div>
</div>
<% else %>
<p class="text-sm text-gray-500">
We couldn't find any transactions to match from your other accounts.
Please select an account and we will create a new inflow transaction for you.
</p>
<%= form.hidden_field :method, value: "new" %>
<div>
<%= form.select :target_account_id,
accounts.map { |account| [account.name, account.id] },
{ label: "Target account" } %>
</div>
<% end %>

View File

@@ -0,0 +1,60 @@
<%= modal_form_wrapper title: "Match transfer or payment" do %>
<%= styled_form_with(
url: account_transaction_transfer_match_path(@entry),
scope: :transfer_match,
class: "space-y-8",
data: { turbo_frame: :_top }
) do |f| %>
<section class="space-y-4">
<div class="space-y-2">
<h2 class="text-sm font-medium text-gray-700">
<%= @entry.amount.positive? ? "From account: #{@entry.account.name}" : "From account" %>
</h2>
<% if @entry.amount.positive? %>
<%= f.select(
:entry_id,
[[entry_name_detailed(@entry), @entry.id]],
{
label: "Outflow transaction",
selected: @entry.id,
},
disabled: true
) %>
<% else %>
<%= render "account/transfer_matches/matching_fields",
form: f, entry: @entry, candidates: @transfer_match_candidates, accounts: @accounts %>
<% end %>
</div>
</section>
<div class="flex justify-center py-2">
<%= lucide_icon "arrow-down", class: "w-5 h-5" %>
</div>
<section class="space-y-4">
<div class="space-y-2">
<h2 class="text-sm font-medium text-gray-700">
<%= @entry.amount.negative? ? "To account: #{@entry.account.name}" : "To account" %>
</h2>
<% if @entry.amount.negative? %>
<%= f.select(
:entry_id,
[[entry_name_detailed(@entry), @entry.id]],
{
label: "Inflow transaction",
selected: @entry.id,
},
disabled: true
) %>
<% else %>
<%= render "account/transfer_matches/matching_fields",
form: f, entry: @entry, candidates: @transfer_match_candidates, accounts: @accounts %>
<% end %>
</div>
</section>
<%= f.submit "Create transfer match", data: { turbo_submits_with: "Saving..."} %>
<% end %>
<% end %>

View File

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

View File

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

View File

@@ -8,6 +8,7 @@
<% end %>
<div class="space-y-3">
<%= form.hidden_field :name, value: "Balance update" %>
<%= form.date_field :date, label: true, required: true, value: Date.current, min: Account::Entry.min_supported_date, max: Date.current %>
<%= form.money_field :amount, label: t(".amount"), required: true %>
</div>

View File

@@ -12,15 +12,15 @@
<% end %>
<div class="flex items-center gap-3">
<%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(color) do %>
<%= lucide_icon icon, class: "w-4 h-4" %>
<%= tag.div class: "w-6 h-6 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(color) do %>
<%= lucide_icon icon, class: "w-4 h-4 shrink-0" %>
<% end %>
<div class="truncate text-gray-900">
<% if entry.new_record? %>
<%= content_tag :p, entry.name %>
<%= content_tag :p, entry.display_name %>
<% else %>
<%= link_to entry.name || t(".balance_update"),
<%= link_to entry.display_name,
account_entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>

View File

@@ -1,5 +1,5 @@
<%# locals: (account:) %>
<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id) do %>
<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id, page: params[:page], tab: params[:tab]) do %>
<%= render "account/entries/loading" %>
<% end %>

View File

@@ -1,9 +1,11 @@
<%# locals: (pagy:) %>
<%# locals: (pagy:, current_path: nil) %>
<nav class="flex w-full items-center justify-between">
<div class="flex items-center gap-1">
<div>
<% if pagy.prev %>
<%= link_to pagy_url_for(pagy, pagy.prev), class: "inline-flex items-center p-2 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
<%= link_to custom_pagy_url_for(pagy, pagy.prev, current_path: current_path),
class: "inline-flex items-center p-2 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700",
data: (current_path ? { turbo_frame: "_top" } : {}) do %>
<%= lucide_icon("chevron-left", class: "w-5 h-5 text-gray-500") %>
<% end %>
<% else %>
@@ -15,11 +17,15 @@
<div class="rounded-xl p-1 bg-gray-25">
<% pagy.series.each do |series_item| %>
<% if series_item.is_a?(Integer) %>
<%= link_to pagy_url_for(pagy, series_item), class: "rounded-md px-2 py-1 inline-flex items-center text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
<%= link_to custom_pagy_url_for(pagy, series_item, current_path: current_path),
class: "rounded-md px-2 py-1 inline-flex items-center text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700",
data: (current_path ? { turbo_frame: "_top" } : {}) do %>
<%= series_item %>
<% end %>
<% elsif series_item.is_a?(String) %>
<%= link_to pagy_url_for(pagy, series_item), class: "rounded-md px-2 py-1 bg-white border border-alpha-black-25 shadow-xs inline-flex items-center text-sm font-medium text-gray-900" do %>
<%= link_to custom_pagy_url_for(pagy, series_item, current_path: current_path),
class: "rounded-md px-2 py-1 bg-white border border-alpha-black-25 shadow-xs inline-flex items-center text-sm font-medium text-gray-900",
data: (current_path ? { turbo_frame: "_top" } : {}) do %>
<%= series_item %>
<% end %>
<% elsif series_item == :gap %>
@@ -29,7 +35,9 @@
</div>
<div>
<% if pagy.next %>
<%= link_to pagy_url_for(pagy, pagy.next), class: "inline-flex items-center p-2 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" do %>
<%= link_to custom_pagy_url_for(pagy, pagy.next, current_path: current_path),
class: "inline-flex items-center p-2 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700",
data: (current_path ? { turbo_frame: "_top" } : {}) do %>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
<% end %>
<% else %>
@@ -40,16 +48,16 @@
</div>
</div>
<div class="flex items-center gap-4">
<%= form_with url: url_for,
<%= form_with url: custom_pagy_url_for(pagy, pagy.page, current_path: current_path),
method: :get,
class: "flex items-center gap-4",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.label :per_page, t(".rows_per_page"), class: "text-sm text-gray-500" %>
<%= f.select :per_page,
<%= f.label :per_page, t(".rows_per_page"), class: "text-sm text-gray-500" %>
<%= f.select :per_page,
options_for_select(["10", "20", "30", "50"], pagy.limit),
{},
class: "py-1.5 pr-8 text-sm text-gray-900 font-medium border border-gray-200 rounded-lg focus:border-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900",
data: { "auto-submit-form-target": "auto" } %>
<% end %>
<% end %>
</div>
</nav>

View File

@@ -0,0 +1,26 @@
<%# locals: (budget:) %>
<div class="space-y-2 mb-6">
<div class="flex items-center gap-2">
<div class="rounded-full w-1.5 h-1.5 <%= budget.allocated_spending > 0 ? "bg-gray-900" : "bg-gray-100" %>"></div>
<p class="text-gray-500 text-sm">
<%= number_to_percentage(budget.allocated_percent, precision: 0) %> set
</p>
<p class="ml-auto text-sm space-x-1">
<span class="text-gray-900"><%= format_money(budget.allocated_spending_money) %></span>
<span class="text-gray-500"> / </span>
<span class="text-gray-500"><%= format_money(budget.budgeted_spending_money) %></span>
</p>
</div>
<div class="relative h-1.5 rounded-2xl bg-gray-100">
<div class="absolute inset-0 bg-gray-900 rounded-2xl" style="width: <%= budget.allocated_percent %>%;"></div>
</div>
<div class="text-sm">
<span class="text-gray-900"><%= format_money(budget.available_to_allocate_money) %></span>
<span class="text-gray-500">left to allocate</span>
</div>
</div>

View File

@@ -0,0 +1,25 @@
<%# locals: (budget:) %>
<div class="space-y-2 mb-6">
<div class="flex items-center gap-2">
<div class="rounded-full w-1.5 h-1.5 bg-red-500"></div>
<p class="text-gray-900 text-sm">&gt; 100% set</p>
<p class="ml-auto text-sm space-x-1">
<span class="text-red-500"><%= format_money(budget.allocated_spending_money) %></span>
<span class="text-gray-500"> / </span>
<span class="text-gray-500"><%= format_money(budget.budgeted_spending_money) %></span>
</p>
</div>
<div class="relative h-1.5 rounded-2xl bg-gray-100">
<div class="absolute inset-0 bg-red-500 rounded-2xl" style="width: 100%;"></div>
</div>
<div class="text-sm">
<p class="text-gray-500">
Budget exceeded by <span class="text-red-500"><%= format_money(budget.available_to_allocate_money.abs) %></span>
</p>
</div>
</div>

View File

@@ -0,0 +1,48 @@
<%# locals: (budget_category:) %>
<%= turbo_frame_tag dom_id(budget_category), class: "w-full" do %>
<%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group w-full p-4 flex items-center gap-3 bg-white", data: { turbo_frame: "drawer" } do %>
<% if budget_category.initialized? %>
<div class="w-10 h-10 group-hover:scale-105 transition-all duration-300">
<%= render "budget_categories/budget_category_donut", budget_category: budget_category %>
</div>
<% else %>
<div class="w-8 h-8 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center" style="<%= mixed_hex_styles(budget_category.category.color) %>">
<% if budget_category.category.lucide_icon %>
<%= icon(budget_category.category.lucide_icon) %>
<% else %>
<%= render "shared/circle_logo", name: budget_category.category.name, hex: budget_category.category.color %>
<% end %>
</div>
<% end %>
<div>
<p class="text-sm font-medium text-gray-900"><%= budget_category.category.name %></p>
<% if budget_category.initialized? %>
<% if budget_category.available_to_spend.negative? %>
<p class="text-sm font-medium text-red-500"><%= format_money(budget_category.available_to_spend_money.abs) %> over</p>
<% elsif budget_category.available_to_spend.zero? %>
<p class="text-sm font-medium <%= budget_category.budgeted_spending.positive? ? "text-orange-500" : "text-gray-500" %>">
<%= format_money(budget_category.available_to_spend_money) %> left
</p>
<% else %>
<p class="text-sm text-gray-500 font-medium"><%= format_money(budget_category.available_to_spend_money) %> left</p>
<% end %>
<% else %>
<p class="text-sm text-gray-500 font-medium">
<%= format_money(budget_category.category.avg_monthly_total) %> avg
</p>
<% end %>
</div>
<div class="ml-auto text-right">
<p class="text-sm font-medium text-gray-900"><%= format_money(budget_category.actual_spending_money) %></p>
<% if budget_category.initialized? %>
<p class="text-sm text-gray-500">from <%= format_money(budget_category.budgeted_spending_money) %></p>
<% end %>
</div>
<% end %>
<% end %>

View File

@@ -0,0 +1,22 @@
<%# locals: (budget_category:) %>
<%= tag.div data: {
controller: "donut-chart",
donut_chart_segments_value: budget_category.to_donut_segments_json,
donut_chart_segment_height_value: 5,
donut_chart_segment_opacity_value: 0.2
}, class: "relative h-full" do %>
<div data-donut-chart-target="chartContainer" class="absolute inset-0 pointer-events-none"></div>
<div data-donut-chart-target="contentContainer" class="flex justify-center items-center h-full p-1">
<div data-donut-chart-target="defaultContent" class="h-full w-full rounded-full flex flex-col items-center justify-center" style="background-color: <%= hex_with_alpha(budget_category.category.color, 0.05) %>">
<% if budget_category.category.lucide_icon %>
<%= lucide_icon budget_category.category.lucide_icon, class: "w-4 h-4 shrink-0", style: "color: #{budget_category.category.color}" %>
<% else %>
<span class="text-sm uppercase" style="color: <%= budget_category.category.color %>">
<%= budget_category.category.name.first.upcase %>
</span>
<% end %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,29 @@
<%# locals: (budget_category:) %>
<% currency = Money::Currency.new(budget_category.budget.currency) %>
<div class="w-full flex gap-3">
<div class="w-1 h-3 rounded-xl mt-1" style="background-color: <%= budget_category.category.color %>"></div>
<div class="text-sm mr-3">
<p class="text-gray-900 font-medium mb-0.5"><%= budget_category.category.name %></p>
<p class="text-gray-500"><%= format_money(Money.new(budget_category.category.avg_monthly_total, budget_category.currency), precision: 0) %>/m average</p>
</div>
<div class="ml-auto">
<%= form_with model: [budget_category.budget, budget_category], data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "blur", turbo_frame: :_top } do |f| %>
<div class="form-field w-[120px]">
<div class="flex items-center">
<span class="text-gray-500 text-sm mr-2"><%= currency.symbol %></span>
<%= f.number_field :budgeted_spending,
class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
placeholder: "0",
step: currency.step,
min: 0,
data: { auto_submit_form_target: "auto" } %>
</div>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,17 @@
<div class="flex justify-center items-center">
<div class="text-center flex flex-col items-center max-w-[500px]">
<h2 class="text-lg text-gray-900 font-medium">Oops!</h2>
<p class="text-gray-500 text-sm max-w-sm mx-auto mb-4">
You have not created or assigned any expense categories to your transactions yet.
</p>
<div class="flex items-center gap-2">
<%= button_to "Use default categories", bootstrap_categories_path, class: "btn btn--primary" %>
<%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span>New category</span>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,21 @@
<%# locals: (budget:) %>
<% budget_category = budget.uncategorized_budget_category %>
<div class="flex gap-3">
<div class="w-1 h-3 rounded-xl mt-1" style="background-color: <%= budget_category.category.color %>"></div>
<div class="text-sm mr-3">
<p class="text-gray-900 font-medium mb-0.5"><%= budget_category.category.name %></p>
<p class="text-gray-500"><%= format_money(Money.new(budget_category.category.avg_monthly_total, budget_category.category.family.currency), precision: 0) %>/m average</p>
</div>
<div class="ml-auto">
<div class="form-field w-[120px]">
<div class="flex items-center">
<span class="text-gray-400 text-sm mr-2">$</span>
<%= text_field_tag :uncategorized, budget_category.budgeted_spending_money, autocomplete: "off", class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", disabled: true %>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,65 @@
<%= content_for :header_nav do %>
<%= render "budgets/budget_nav", budget: @budget %>
<% end %>
<%= content_for :previous_path, edit_budget_path(@budget) %>
<%= content_for :cancel_path, budget_path(@budget) %>
<div>
<div class="space-y-6">
<div class="text-center space-y-2">
<h1 class="text-3xl text-gray-900 font-medium">Edit your category budgets</h1>
<p class="text-gray-500 text-sm max-w-md mx-auto">
Adjust category budgets to set spending limits. Unallocated funds will be automatically assigned as uncategorized.
</p>
</div>
<div class="mx-auto max-w-lg">
<% if @budget.family.categories.empty? %>
<div class="bg-white shadow-xs border border-gray-200 rounded-lg p-4">
<%= render "budget_categories/no_categories" %>
</div>
<% else %>
<div class="max-w-md mx-auto">
<% if @budget.available_to_allocate.negative? %>
<%= render "budget_categories/allocation_progress_overage", budget: @budget %>
<% else %>
<%= render "budget_categories/allocation_progress", budget: @budget %>
<% end %>
<div class="space-y-4 mb-4">
<% BudgetCategory::Group.for(@budget.budget_categories).sort_by(&:name).each do |group| %>
<div class="space-y-4">
<%= render "budget_categories/budget_category_form", budget_category: group.budget_category %>
<div class="space-y-4">
<% group.budget_subcategories.each do |budget_subcategory| %>
<div class="w-full flex items-center gap-4">
<div class="ml-4 flex items-center justify-center text-gray-400">
<%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %>
</div>
<%= render "budget_categories/budget_category_form", budget_category: budget_subcategory %>
</div>
<% end %>
</div>
</div>
<% end %>
<%= render "budget_categories/uncategorized_budget_category_form", budget: @budget %>
</div>
<% if @budget.allocations_valid? %>
<%= link_to "Confirm",
budget_path(@budget),
class: "block btn btn--primary w-full text-center" %>
<% else %>
<span class="block btn btn--secondary w-full text-center text-gray-400 cursor-not-allowed">
Confirm
</span>
<% end %>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,150 @@
<%= drawer do %>
<div class="space-y-4">
<header class="flex justify-between">
<div>
<p class="text-sm text-gray-500">Category</p>
<h3 class="text-2xl font-medium text-gray-900">
<%= @budget_category.category.name %>
</h3>
<% if @budget_category.budget.initialized? %>
<p class="text-sm text-gray-500">
<span class="text-gray-900">
<%= format_money(@budget_category.actual_spending) %>
</span>
<span>/</span>
<span><%= format_money(@budget_category.budgeted_spending) %></span>
</p>
<% end %>
</div>
<% if @budget_category.budget.initialized? %>
<div class="ml-auto w-10 h-10">
<%= render "budget_categories/budget_category_donut",
budget_category: @budget_category %>
</div>
<% end %>
</header>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2
text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4>Overview</h4>
<%= lucide_icon "chevron-down",
class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-4">
<dl class="space-y-3 px-3 py-2">
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500">
<%= @budget_category.budget.start_date.strftime("%b %Y") %> spending
</dt>
<dd class="text-gray-900 font-medium">
<%= format_money @budget_category.actual_spending_money %>
</dd>
</div>
<% if @budget_category.budget.initialized? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500">Status</dt>
<% if @budget_category.available_to_spend.negative? %>
<dd class="text-red-500 flex items-center gap-1 text-red-500 font-medium">
<%= lucide_icon "alert-circle", class: "shrink-0 w-4 h-4 text-red-500" %>
<%= format_money @budget_category.available_to_spend_money.abs %>
<span>overspent</span>
</dd>
<% elsif @budget_category.available_to_spend.zero? %>
<dd class="text-orange-500 flex items-center gap-1 text-orange-500 font-medium">
<%= lucide_icon "x-circle", class: "shrink-0 w-4 h-4 text-orange-500" %>
<%= format_money @budget_category.available_to_spend_money %>
<span>left</span>
</dd>
<% else %>
<dd class="text-gray-900 flex items-center gap-1 text-green-500 font-medium">
<%= lucide_icon "check-circle-2", class: "shrink-0 w-4 h-4 text-green-500" %>
<%= format_money @budget_category.available_to_spend_money %>
<span>left</span>
</dd>
<% end %>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500">Budgeted</dt>
<dd class="text-gray-900 font-medium">
<%= format_money @budget_category.budgeted_spending %>
</dd>
</div>
<% end %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500">Monthly average spending</dt>
<dd class="text-gray-900 font-medium">
<%= format_money @budget_category.category.avg_monthly_total %>
</dd>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500">Monthly median spending</dt>
<dd class="text-gray-900 font-medium">
<%= format_money @budget_category.category.median_monthly_total %>
</dd>
</div>
</dl>
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2
text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4>Recent Transactions</h4>
<%= lucide_icon "chevron-down",
class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="space-y-2">
<div class="px-3 py-4 space-y-2">
<% if @recent_transactions.any? %>
<ul class="space-y-2 mb-4">
<% @recent_transactions.each_with_index do |entry, index| %>
<li class="flex gap-4 text-sm space-y-1">
<div class="flex flex-col items-center gap-1.5 pt-2">
<div class="rounded-full h-1.5 w-1.5 bg-gray-300"></div>
<% unless index == @recent_transactions.length - 1 %>
<div class="h-12 w-px bg-alpha-black-200"></div>
<% end %>
</div>
<div class="flex justify-between w-full">
<div>
<p class="text-gray-500 text-xs uppercase">
<%= entry.date.strftime("%b %d") %>
</p>
<p class="text-gray-900"><%= entry.name %></p>
</div>
<p class="text-gray-900 font-medium">
<%= format_money entry.amount_money %>
</p>
</div>
</li>
<% end %>
</ul>
<%= link_to "View all category transactions",
transactions_path(q: {
categories: [@budget_category.category.name],
start_date: @budget.start_date,
end_date: @budget.end_date
}),
data: { turbo_frame: :_top },
class: "block text-center btn btn--outline w-full" %>
<% else %>
<p class="text-gray-500 text-sm mb-4">
No transactions found for this budget period.
</p>
<% end %>
</div>
</div>
</details>
</div>
<% end %>

View File

@@ -0,0 +1,62 @@
<%# locals: (budget:) %>
<div>
<div class="p-4 border-b border-gray-100">
<h3 class="text-sm text-gray-500 mb-2">Income</h3>
<% income_totals = budget.income_categories_with_totals %>
<% income_categories = income_totals.category_totals.reject { |ct| ct.amount_money.zero? }.sort_by { |ct| ct.percentage }.reverse %>
<span class="inline-block mb-2 text-xl font-medium text-gray-900">
<%= format_money(income_totals.total_money) %>
</span>
<% if income_categories.any? %>
<div>
<div class="flex h-1.5 mb-3 gap-1">
<% income_categories.each do |item| %>
<div class="h-full rounded-xs" style="background-color: <%= item.category.color %>; width: <%= item.percentage %>%"></div>
<% end %>
</div>
<div class="flex flex-wrap gap-x-2.5 gap-y-1 text-xs">
<% income_categories.each do |item| %>
<div class="flex items-center gap-1.5">
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background-color: <%= item.category.color %>"></div>
<span class="text-gray-500"><%= item.category.name %></span>
<span class="text-gray-900"><%= number_to_percentage(item.percentage, precision: 0) %></span>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<div class="p-4">
<h3 class="text-sm text-gray-500 mb-2">Expenses</h3>
<% expense_totals = budget.expense_categories_with_totals %>
<% expense_categories = expense_totals.category_totals.reject { |ct| ct.amount_money.zero? || ct.category.subcategory? }.sort_by { |ct| ct.percentage }.reverse %>
<span class="inline-block mb-2 text-xl font-medium text-gray-900"><%= format_money(expense_totals.total_money) %></span>
<% if expense_categories.any? %>
<div>
<div class="flex h-1.5 mb-3 gap-1">
<% expense_categories.each do |item| %>
<div class="h-full rounded-xs" style="background-color: <%= item.category.color %>; width: <%= item.percentage %>%"></div>
<% end %>
</div>
<div class="flex flex-wrap gap-x-2.5 gap-y-1 text-xs">
<% expense_categories.each do |item| %>
<div class="flex items-center gap-1.5">
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background-color: <%= item.category.color %>"></div>
<span class="text-gray-500"><%= item.category.name %></span>
<span class="text-gray-900"><%= number_to_percentage(item.percentage, precision: 0) %></span>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,45 @@
<%# locals: (budget:) %>
<div>
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500 uppercase">
<p>Categories</p>
<span class="text-gray-400">&middot;</span>
<p><%= budget.budget_categories.count %></p>
<p class="ml-auto">Amount</p>
</div>
<div class="bg-white py-1 shadow-xs border border-gray-100 rounded-md">
<% if budget.family.categories.expenses.empty? %>
<div class="py-8">
<%= render "budget_categories/no_categories" %>
</div>
<% else %>
<% category_groups = BudgetCategory::Group.for(budget.budget_categories) %>
<% category_groups.each_with_index do |group, index| %>
<div>
<%= render "budget_categories/budget_category", budget_category: group.budget_category %>
<div>
<% group.budget_subcategories.each do |budget_subcategory| %>
<div class="w-full flex items-center -mt-4">
<div class="ml-8 flex items-center justify-center text-gray-400">
<%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %>
</div>
<%= render "budget_categories/budget_category", budget_category: budget_subcategory %>
</div>
<% end %>
</div>
</div>
<div class="px-4">
<div class="h-px w-full bg-alpha-black-50"></div>
</div>
<% end %>
<%= render "budget_categories/budget_category", budget_category: budget.uncategorized_budget_category %>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,61 @@
<%= tag.div data: { controller: "donut-chart", donut_chart_segments_value: budget.to_donut_segments_json }, class: "relative h-full" do %>
<div data-donut-chart-target="chartContainer" class="absolute inset-0 pointer-events-none"></div>
<div data-donut-chart-target="contentContainer" class="flex justify-center items-center h-full">
<div data-donut-chart-target="defaultContent" class="flex flex-col items-center">
<% if budget.initialized? %>
<div class="text-gray-600 text-sm mb-2">
<span>Spent</span>
</div>
<div class="text-3xl font-medium <%= budget.available_to_spend.negative? ? "text-red-500" : "text-gray-900" %>">
<%= format_money(budget.actual_spending_money) %>
</div>
<%= link_to edit_budget_path(budget), class: "btn btn--secondary flex items-center gap-1 mt-2" do %>
<span class="text-gray-900 font-medium">
of <%= format_money(budget.budgeted_spending_money) %>
</span>
<%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 hover:text-gray-600" %>
<% end %>
<% else %>
<div class="text-gray-400 text-3xl mb-2">
<span><%= format_money Money.new(0, budget.currency || budget.family.currency) %></span>
</div>
<%= link_to edit_budget_path(budget), class: "flex items-center gap-2 btn btn--primary" do %>
<%= lucide_icon "plus", class: "w-4 h-4 text-white" %>
New budget
<% end %>
<% end %>
</div>
<% budget.budget_categories.each do |bc| %>
<div id="segment_<%= bc.id %>" class="hidden">
<div class="flex flex-col gap-2 items-center">
<div class="flex items-center gap-3">
<div class="w-1 h-3 rounded-xl" style="background-color: <%= bc.category.color %>"></div>
<p class="text-sm text-gray-500"><%= bc.category.name %></p>
</div>
<p class="text-3xl font-medium <%= bc.available_to_spend.negative? ? "text-red-500" : "text-gray-900" %>">
<%= format_money(bc.actual_spending_money) %>
</p>
<%= link_to budget_budget_categories_path(budget), class: "btn btn--secondary flex items-center gap-1" do %>
<span>of <%= format_money(bc.budgeted_spending_money, precision: 0) %></span>
<%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 shrink-0" %>
<% end %>
</div>
</div>
<% end %>
<div id="segment_unused" class="hidden">
<p class="text-sm text-gray-500 text-center mb-2">Unused</p>
<p class="text-3xl font-medium text-gray-900">
<%= format_money(budget.available_to_spend_money) %>
</p>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,40 @@
<%# locals: (budget:, previous_budget:, next_budget:, latest_budget:) %>
<div class="flex items-center gap-1 mb-4">
<div class="flex items-center gap-2">
<% if @previous_budget %>
<%= link_to budget_path(@previous_budget) do %>
<%= lucide_icon "chevron-left" %>
<% end %>
<% else %>
<%= lucide_icon "chevron-left", class: "text-gray-400" %>
<% end %>
<% if @next_budget %>
<%= link_to budget_path(@next_budget) do %>
<%= lucide_icon "chevron-right" %>
<% end %>
<% else %>
<%= lucide_icon "chevron-right", class: "text-gray-400" %>
<% end %>
</div>
<div data-controller="menu" data-menu-placement-value="bottom-start">
<%= tag.button data: { menu_target: "button" }, class: "flex items-center gap-1 hover:bg-gray-50 rounded-md p-2" do %>
<span class="text-gray-900 font-medium"><%= @budget.name %></span>
<%= lucide_icon "chevron-down", class: "w-5 h-5 shrink-0 text-gray-500" %>
<% end %>
<div data-menu-target="content" class="hidden z-10">
<%= render "budgets/picker", family: Current.family, year: Date.current.year %>
</div>
</div>
<div class="ml-auto">
<% if @budget.current? %>
<span class="border border-alpha-black-200 text-gray-900 text-sm font-medium px-3 py-2 rounded-lg">Today</span>
<% else %>
<%= link_to "Today", budget_path(@latest_budget), class: "btn btn--outline" %>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,37 @@
<%# locals: (budget:) %>
<% steps = [
{ name: "Setup", path: edit_budget_path(budget), is_complete: budget.initialized?, step_number: 1 },
{ name: "Categories", path: budget_budget_categories_path(budget), is_complete: budget.allocations_valid?, step_number: 2 },
] %>
<ul class="flex items-center gap-2">
<% steps.each_with_index do |step, idx| %>
<li class="flex items-center gap-2 group">
<% is_current = request.path == step[:path] %>
<% text_class = if is_current
"text-gray-900"
else
step[:is_complete] ? "text-green-600" : "text-gray-500"
end %>
<% step_class = if is_current
"bg-gray-900 text-white"
else
step[:is_complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-gray-50"
end %>
<%= link_to step[:path], class: "flex items-center gap-3" do %>
<div class="flex items-center gap-2 text-sm font-medium <%= text_class %>">
<span class="<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent">
<%= step[:is_complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : idx + 1 %>
</span>
<span><%= step[:name] %></span>
</div>
<% end %>
<div class="h-px bg-alpha-black-200 w-12 group-last:hidden"></div>
</li>
<% end %>
</ul>

View File

@@ -0,0 +1,63 @@
<%# locals: (budget:) %>
<div>
<div class="p-4 border-b border-gray-100">
<h3 class="text-sm text-gray-500 mb-2">Expected income</h3>
<span class="inline-block mb-2 text-xl font-medium text-gray-900">
<%= format_money(budget.expected_income_money) %>
</span>
<div>
<div class="flex h-1.5 mb-3 gap-1">
<% if budget.remaining_expected_income.negative? %>
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= 100 - budget.surplus_percent %>%"></div>
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= budget.surplus_percent %>%"></div>
<% else %>
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= budget.actual_income_percent %>%"></div>
<div class="rounded-md h-1.5 bg-gray-100" style="width: <%= 100 - budget.actual_income_percent %>%"></div>
<% end %>
</div>
<div class="flex justify-between text-sm">
<p class="text-gray-500"><%= format_money(budget.actual_income_money) %> earned</p>
<p class="font-medium">
<% if budget.remaining_expected_income.negative? %>
<span class="text-green-500"><%= format_money(budget.remaining_expected_income_money.abs) %> over</span>
<% else %>
<span class="text-gray-900"><%= format_money(budget.remaining_expected_income_money) %> left</span>
<% end %>
</p>
</div>
</div>
</div>
<div class="p-4">
<h3 class="text-sm text-gray-500 mb-2">Budgeted</h3>
<span class="inline-block mb-2 text-xl font-medium text-gray-900">
<%= format_money(budget.budgeted_spending_money) %>
</span>
<div>
<div class="flex h-1.5 mb-3 gap-1">
<% if budget.available_to_spend.negative? %>
<div class="rounded-md h-1.5 bg-gray-900" style="width: <%= 100 - budget.overage_percent %>%"></div>
<div class="rounded-md h-1.5 bg-red-500" style="width: <%= budget.overage_percent %>%"></div>
<% else %>
<div class="rounded-md h-1.5 bg-gray-900" style="width: <%= budget.percent_of_budget_spent %>%"></div>
<div class="rounded-md h-1.5 bg-gray-100" style="width: <%= 100 - budget.percent_of_budget_spent %>%"></div>
<% end %>
</div>
<div class="flex justify-between text-sm">
<p class="text-gray-500"><%= format_money(budget.actual_spending_money) %> spent</p>
<p class="font-medium">
<% if budget.available_to_spend.negative? %>
<span class="text-red-500"><%= format_money(budget.available_to_spend_money.abs) %> over</span>
<% else %>
<span class="text-gray-900"><%= format_money(budget.available_to_spend_money) %> left</span>
<% end %>
</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,13 @@
<%# locals: (budget:) %>
<div class="flex flex-col gap-4 items-center justify-center h-full">
<%= lucide_icon "alert-triangle", class: "w-6 h-6 text-red-500" %>
<p class="text-gray-500 text-sm text-center">You have over-allocated your budget. Please fix your allocations.</p>
<%= link_to budget_budget_categories_path(budget), class: "btn btn--secondary flex items-center gap-1" do %>
<span class="text-gray-900 font-medium">
Fix allocations
</span>
<%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 hover:text-gray-600" %>
<% end %>
</div>

View File

@@ -0,0 +1,49 @@
<%# locals: (family:, year:) %>
<%= turbo_frame_tag "budget_picker" do %>
<div class="bg-white shadow-md border border-alpha-black-25 p-3 rounded-xl space-y-4">
<div class="flex items-center gap-2 justify-between">
<% if year > family.oldest_entry_date.year %>
<%= link_to picker_budgets_path(year: year - 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md" do %>
<%= lucide_icon "chevron-left", class: "w-5 h-5 shrink-0 text-gray-500" %>
<% end %>
<% else %>
<span class="p-2 flex items-center justify-center text-gray-300 rounded-md">
<%= lucide_icon "chevron-left", class: "w-5 h-5 shrink-0 text-gray-400" %>
</span>
<% end %>
<span class="w-40 text-center px-3 py-2 border border-alpha-black-100 rounded-md" data-budget-picker-target="year">
<%= year %>
</span>
<% if year < Date.current.year %>
<%= link_to picker_budgets_path(year: year + 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md" do %>
<%= lucide_icon "chevron-right", class: "w-5 h-5 shrink-0 text-gray-500" %>
<% end %>
<% else %>
<span class="p-2 flex items-center justify-center text-gray-300 rounded-md">
<%= lucide_icon "chevron-right", class: "w-5 h-5 shrink-0 text-gray-400" %>
</span>
<% end %>
</div>
<div class="grid grid-cols-3 gap-2 text-sm text-center font-medium">
<% Date::ABBR_MONTHNAMES.compact.each_with_index do |month_name, index| %>
<% month_number = index + 1 %>
<% start_date = Date.new(year, month_number) %>
<% budget = family.budgets.for_date(start_date) %>
<% if budget %>
<%= link_to month_name, budget_path(budget), data: { turbo_frame: "_top" }, class: "block px-3 py-2 text-sm text-gray-900 hover:bg-gray-100 rounded-md" %>
<% elsif start_date >= family.oldest_entry_date.beginning_of_month && start_date <= Date.current %>
<%= button_to budgets_path(budget: { start_date: start_date }), data: { turbo_frame: "_top" }, class: "block w-full px-3 py-2 text-gray-900 hover:bg-gray-100 rounded-md" do %>
<%= month_name %>
<% end %>
<% else %>
<span class="px-3 py-2 text-gray-400 rounded-md"><%= month_name %></span>
<% end %>
<% end %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,47 @@
<%= content_for :header_nav do %>
<%= render "budgets/budget_nav", budget: @budget %>
<% end %>
<%= content_for :previous_path, budget_path(@budget) %>
<%= content_for :cancel_path, budget_path(@budget) %>
<div>
<div class="space-y-4">
<div class="text-center space-y-2">
<h1 class="text-3xl text-gray-900 font-medium">Setup your budget</h1>
<p class="text-gray-500 text-sm max-w-sm mx-auto">
Enter your monthly earnings and planned spending below to setup your budget.
</p>
</div>
<div class="mx-auto max-w-lg">
<%= styled_form_with model: @budget, class: "space-y-3", data: { controller: "budget-form" } do |f| %>
<%= f.money_field :budgeted_spending, label: "Budgeted spending", required: true, disable_currency: true %>
<%= f.money_field :expected_income, label: "Expected income", required: true, disable_currency: true %>
<% if @budget.estimated_income && @budget.estimated_spending %>
<div class="border border-alpha-black-100 rounded-lg p-3 flex">
<%= lucide_icon "sparkles", class: "w-5 h-5 text-gray-500 shrink-0" %>
<div class="ml-2 space-y-1 text-sm">
<h4 class="text-gray-900">Autosuggest income & spending budget</h4>
<p class="text-gray-500">
This will be based on transaction history. AI can make mistakes, verify before continuing.
</p>
</div>
<div class="relative inline-block select-none ml-6">
<%= check_box_tag :auto_fill, "1", params[:auto_fill].present?, class: "sr-only peer", data: {
action: "change->budget-form#toggleAutoFill",
budget_form_income_param: { key: "budget_expected_income", value: @budget.estimated_income },
budget_form_spending_param: { key: "budget_budgeted_spending", value: @budget.estimated_spending }
} %>
<label for="auto_fill" class="maybe-switch"></label>
</div>
</div>
<% end %>
<%= f.submit "Continue", class: "btn btn--primary w-full" %>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,69 @@
<div class="pb-12">
<%= render "budgets/budget_header",
budget: @budget,
previous_budget: @previous_budget,
next_budget: @next_budget,
latest_budget: @latest_budget %>
<div class="flex items-start gap-4">
<div class="w-[300px] space-y-4">
<div class="h-[300px] bg-white rounded-xl shadow-xs p-8 border border-gray-100">
<% if @budget.available_to_allocate.negative? %>
<%= render "budgets/over_allocation_warning", budget: @budget %>
<% else %>
<%= render "budgets/budget_donut", budget: @budget %>
<% end %>
</div>
<div>
<% if @budget.initialized? && @budget.available_to_allocate.positive? %>
<div class="flex gap-2 mb-2 rounded-lg bg-alpha-black-25 p-1">
<% base_classes = "rounded-md px-2 py-1 flex-1 text-center" %>
<% selected_tab = params[:tab].presence || "budgeted" %>
<%= link_to "Budgeted",
budget_path(@budget, tab: "budgeted"),
class: class_names(
base_classes,
"bg-white shadow-xs text-gray-900": selected_tab == "budgeted",
"text-gray-500": selected_tab != "budgeted"
) %>
<%= link_to "Actual",
budget_path(@budget, tab: "actuals"),
class: class_names(
base_classes,
"bg-white shadow-xs text-gray-900": selected_tab == "actuals",
"text-gray-500": selected_tab != "actuals"
) %>
</div>
<div class="bg-white rounded-xl shadow-xs border border-gray-100">
<%= render selected_tab == "budgeted" ? "budgets/budgeted_summary" : "budgets/actuals_summary", budget: @budget %>
</div>
<% else %>
<div class="bg-white rounded-xl shadow-xs border border-gray-100">
<%= render "budgets/actuals_summary", budget: @budget %>
</div>
<% end %>
</div>
</div>
<div class="grow bg-white rounded-xl shadow-xs p-4 border border-gray-100">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium">Categories</h2>
<% if @budget.initialized? %>
<%= link_to budget_budget_categories_path(@budget), class: "btn btn--secondary flex items-center gap-2" do %>
<%= icon "settings-2", color: "gray" %>
<span>Edit</span>
<% end %>
<% end %>
</div>
<div class="bg-gray-25 rounded-xl p-1">
<%= render "budgets/budget_categories", budget: @budget %>
</div>
</div>
</div>
</div>

View File

@@ -1,12 +1,16 @@
<%# locals: (category:) %>
<% category ||= null_category %>
<% category ||= Category.uncategorized %>
<div>
<span class="flex items-center gap-1 text-sm font-medium rounded-full px-1.5 py-1 border"
<span class="flex items-center gap-1 text-sm font-medium rounded-full px-1.5 py-1 border truncate"
style="
background-color: color-mix(in srgb, <%= category.color %> 5%, white);
border-color: color-mix(in srgb, <%= category.color %> 30%, white);
color: <%= category.color %>;">
<% if category.lucide_icon.present? %>
<%= lucide_icon category.lucide_icon, class: "w-4 h-4 shrink-0" %>
<% end %>
<%= category.name %>
</span>
</div>

View File

@@ -1,7 +1,11 @@
<%# locals: (category:) %>
<div id="<%= dom_id(category) %>" class="flex justify-between items-center p-4 bg-white">
<div id="<%= dom_id(category) %>" class="flex justify-between items-center px-4 pb-4 <%= "pt-4" unless category.subcategory? %> <%= "pb-4" unless category.subcategories.any? %> bg-white">
<div class="flex w-full items-center gap-2.5">
<% if category.subcategory? %>
<%= lucide_icon "corner-down-right", class: "shrink-0 w-5 h-5 text-gray-400 ml-2" %>
<% end %>
<%= render partial: "categories/badge", locals: { category: category } %>
</div>
<div class="justify-self-end">

View File

@@ -0,0 +1,25 @@
<%# locals: (title:, categories:) %>
<div class="rounded-xl bg-gray-25 space-y-1 p-1">
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500 uppercase">
<p><%= title %></p>
<span class="text-gray-400">&middot;</span>
<p><%= categories.count %></p>
</div>
<div class="border border-alpha-black-25 rounded-md bg-white shadow-xs">
<div class="overflow-hidden rounded-md">
<% Category::Group.for(categories).each_with_index do |group, idx| %>
<%= render group.category %>
<% group.subcategories.each do |subcategory| %>
<%= render subcategory %>
<% end %>
<% unless idx == Category::Group.for(categories).count - 1 %>
<%= render "categories/ruler" %>
<% end %>
<% end %>
</div>
</div>
</div>

View File

@@ -1,9 +1,12 @@
<%# locals: (category:, categories:) %>
<div data-controller="color-avatar">
<%= styled_form_with model: category, class: "space-y-4", data: { turbo_frame: :_top } do |f| %>
<%= styled_form_with model: category, class: "space-y-4" do |f| %>
<section class="space-y-4">
<div class="w-fit m-auto">
<%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %>
</div>
<div class="flex gap-2 items-center justify-center">
<% Category::COLORS.each do |color| %>
<label class="relative">
@@ -12,8 +15,26 @@
</label>
<% end %>
</div>
<div class="relative flex items-center border border-gray-200 rounded-lg">
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, data: { color_avatar_target: "name" } %>
<% if category.errors.any? %>
<%= render "shared/form_errors", model: category %>
<% end %>
<div class="flex flex-wrap gap-2 justify-center mb-4">
<% Category.icon_codes.each do |icon| %>
<label class="relative">
<%= f.radio_button :lucide_icon, icon, class: "sr-only peer" %>
<div class="p-1 rounded cursor-pointer hover:bg-gray-100 peer-checked:bg-gray-100 border-1 border-transparent peer-checked:border-gray-500">
<%= lucide_icon icon, class: "w-5 h-5" %>
</div>
</label>
<% end %>
</div>
<div class="space-y-2">
<%= f.select :classification, [["Income", "income"], ["Expense", "expense"]], { label: "Classification" }, required: true %>
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %>
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" } %>
</div>
</section>

View File

@@ -5,7 +5,7 @@
<%= render partial: "categories/badge", locals: { category: transaction.category } %>
</button>
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
<div class="w-64 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<div class="w-80 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
<div class="p-6 flex items-center justify-center">
<p class="text-sm text-gray-500 animate-pulse"><%= t(".loading") %></p>

View File

@@ -1,3 +1,3 @@
<%= modal_form_wrapper title: t(".edit") do %>
<%= render "form", category: @category %>
<%= render "form", category: @category, categories: @categories %>
<% end %>

View File

@@ -14,27 +14,27 @@
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
<% if @categories.any? %>
<div class="rounded-xl bg-gray-25 space-y-1 p-1">
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500 uppercase">
<p><%= t(".categories") %></p>
<span class="text-gray-400">&middot;</span>
<p><%= @categories.count %></p>
</div>
<div class="space-y-4">
<% if @categories.incomes.any? %>
<%= render "categories/category_list_group", title: t(".categories_incomes"), categories: @categories.incomes %>
<% end %>
<div class="border border-alpha-black-25 rounded-md bg-white shadow-xs">
<div class="overflow-hidden rounded-md">
<%= render partial: @categories, spacer_template: "categories/ruler" %>
</div>
</div>
<% if @categories.expenses.any? %>
<%= render "categories/category_list_group", title: t(".categories_expenses"), categories: @categories.expenses %>
<% end %>
</div>
<% else %>
<div class="flex justify-center items-center py-20">
<div class="text-center flex flex-col items-center max-w-[300px]">
<p class="text-gray-900 mb-1 font-medium text-sm"><%= t(".empty") %></p>
<%= link_to new_category_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new") %></span>
<% end %>
<div class="text-center flex flex-col items-center max-w-[500px]">
<p class="text-sm text-gray-500 mb-4"><%= t(".empty") %></p>
<div class="flex items-center gap-2">
<%= button_to t(".bootstrap"), bootstrap_categories_path, class: "btn btn--primary" %>
<%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new") %></span>
<% end %>
</div>
</div>
</div>
<% end %>

View File

@@ -1,3 +1,3 @@
<%= modal_form_wrapper title: t(".new_category") do %>
<%= render "form", category: @category %>
<%= render "form", category: @category, categories: @categories %>
<% end %>

View File

@@ -15,6 +15,9 @@
<span class="w-5 h-5">
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
</span>
<% if category.subcategory? %>
<%= lucide_icon "corner-down-right", class: "shrink-0 w-5 h-5 text-gray-400" %>
<% end %>
<%= render partial: "categories/badge", locals: { category: category } %>
<% end %>

View File

@@ -10,22 +10,27 @@
<div class="pb-2 pl-4 mr-2 text-gray-500 hidden" data-list-filter-target="emptyMessage">
<%= t(".no_categories") %>
</div>
<% @categories.each do |category| %>
<%= render partial: "category/dropdowns/row", locals: { category: } %>
<% if @categories.any? %>
<% Category::Group.for(@categories).each do |group| %>
<%= render "category/dropdowns/row", category: group.category %>
<% group.subcategories.each do |category| %>
<%= render "category/dropdowns/row", category: category %>
<% end %>
<% end %>
<% else %>
<div class="flex justify-center items-center py-12">
<div class="text-center flex flex-col items-center max-w-[500px]">
<p class="text-sm text-gray-500 font-normal mb-4"><%= t(".empty") %></p>
<%= button_to t(".bootstrap"), bootstrap_categories_path, class: "btn btn--outline", data: { turbo_frame: :_top } %>
</div>
</div>
<% end %>
</div>
<hr>
<div class="relative p-1.5 w-full">
<%= link_to new_category_path(transaction_id: @transaction),
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100",
data: { turbo_frame: "modal" } do %>
<%= lucide_icon "plus", class: "w-5 h-5" %>
<%= t(".add_new") %>
<% end %>
<% if @transaction.category %>
<%= button_to account_transaction_path(@transaction.entry.account, @transaction.entry),
<%= button_to account_transaction_path(@transaction.entry),
method: :patch,
data: { turbo_frame: dom_id(@transaction.entry) },
params: { account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
@@ -35,6 +40,34 @@
<%= t(".clear") %>
<% end %>
<% end %>
<% unless @transaction.transfer? %>
<%= link_to new_account_transaction_transfer_match_path(@transaction.entry),
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100",
data: { turbo_frame: "modal" } do %>
<%= lucide_icon "refresh-cw", class: "w-5 h-5" %>
<p>Match transfer/payment</p>
<% end %>
<% end %>
<div class="flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2">
<div class="flex items-center gap-2">
<%= form_with url: account_transaction_path(@transaction.entry),
method: :patch,
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field "account_entry[excluded]", value: !@transaction.entry.excluded %>
<%= f.check_box "account_entry[excluded]",
checked: @transaction.entry.excluded,
class: "maybe-checkbox maybe-checkbox--light",
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change" } %>
<% end %>
</div>
<p>One-time <%= @transaction.entry.amount.negative? ? "income" : "expense" %></p>
<%= lucide_icon "asterisk", class: "w-5 h-5 shrink-0 text-orange-500 ml-auto" %>
</div>
</div>
</div>
<% end %>

View File

@@ -81,6 +81,9 @@
<li>
<%= sidebar_link_to t(".transactions"), transactions_path, icon: "credit-card" %>
</li>
<li>
<%= sidebar_link_to t(".budgeting"), budgets_path, icon: "map" %>
</li>
</ul>
</nav>
<div class="flex flex-col mt-6">

View File

@@ -13,7 +13,6 @@
<%= combobox_style_tag %>
<%= javascript_importmap_tags %>
<%= hotwire_livereload_tags if Rails.env.development? %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<meta name="viewport"

View File

@@ -0,0 +1,23 @@
<%= content_for :content do %>
<div class="flex flex-col h-dvh">
<header class="flex items-center justify-between p-8">
<%= link_to content_for(:previous_path) || root_path do %>
<%= lucide_icon "arrow-left", class: "w-5 h-5 text-gray-500" %>
<% end %>
<nav>
<%= yield :header_nav %>
</nav>
<%= link_to content_for(:cancel_path) || root_path do %>
<%= lucide_icon "x", class: "text-gray-500 w-5 h-5" %>
<% end %>
</header>
<main class="flex-grow px-8 pt-12 pb-32 overflow-y-auto">
<%= yield %>
</main>
</div>
<% end %>
<%= render template: "layouts/application" %>

View File

@@ -2,7 +2,14 @@
<div class="flex justify-between items-center p-4 bg-white">
<div class="flex w-full items-center gap-2.5">
<%= render partial: "shared/color_avatar", locals: { name: merchant.name, color: merchant.color } %>
<% if merchant.icon_url %>
<div class="w-8 h-8 rounded-full flex justify-center items-center">
<%= image_tag merchant.icon_url, class: "w-8 h-8 rounded-full" %>
</div>
<% else %>
<%= render partial: "shared/color_avatar", locals: { name: merchant.name, color: merchant.color } %>
<% end %>
<p class="text-gray-900 text-sm truncate">
<%= merchant.name %>
</p>

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