Compare commits

...

133 Commits

Author SHA1 Message Date
Zach Gollwitzer
d9f11e002a Release v0.1.0
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-10-11 13:15:46 -04:00
Zach Gollwitzer
c744237b55 Allow cents in start balance for accounts 2024-10-11 12:43:50 -04:00
Zach Gollwitzer
7dfd7408c7 Show correct precision on account page 2024-10-11 11:49:53 -04:00
Zach Gollwitzer
8f8988c03a Fix minified JS in prod for chart controller 2024-10-11 11:37:33 -04:00
Zach Gollwitzer
a21061fb56 Private method syntax fix in prod 2024-10-11 11:34:51 -04:00
Alex Hatzenbuhler
c5bf1db230 Add additional subtypes, add None option, prefill edit with previously selected option. (#1286)
* Add additional subtypes and allow for None

* Add parens for consistency on 401

* Remove cryptocurrency investment subtype

* Handle nil value

* Use objects current subtype as the initial selection

* Remove "None" option to default to helper prompt

* Fix blank/none selection

* Only include blank if subtype is present

* Simplify investment subtype dropdown

* Improve depository subtype
2024-10-10 21:23:56 -04:00
Zach Gollwitzer
3610c6cae7 Add observed holidays to sync exceptions 2024-10-10 19:29:20 -04:00
Zach Gollwitzer
79ca7e2039 Preserve negative sign on raw CSV values 2024-10-10 18:57:00 -04:00
Aaron Meese
34ebd96c4c fix: default value if user's name isn't set (#1262)
* fix: default value if user's name isn't set

* chore: matched code style

* fix: i18n key for fallback greeting

---------

Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-10-10 18:03:47 -04:00
Zach Gollwitzer
3399b74849 Handle market holidays during holding sync (#1292)
* Handle market holidays during holding sync

* Use informal holidays instead of custom override
2024-10-10 18:02:12 -04:00
Arsen Shkrumelyak
77fc5caecf Minor improvements to categories & changelog pages (#1274)
* ui: change category badge border color

* ui/ux: use author's name in changelog

* ui: badge border 25% -> 30%
2024-10-10 16:00:35 -04:00
Zach Gollwitzer
a20809eee3 When unassigned accounts in CSV import, always allow new account creation 2024-10-10 15:51:36 -04:00
Zach Gollwitzer
cd9f20747c Allow inline account creation when importing CSV (#1291)
* Allow inline account creation when importing CSV

* Sanitize numeric inputs for CSV

* CSV import date validation

* Lint fix
2024-10-10 15:14:38 -04:00
Josh Pigford
1746533842 Default to "today" when entering a transactions and value entries 2024-10-10 12:24:20 -05:00
Josh Pigford
6b46831199 Intercom data update 2024-10-10 10:59:06 -05:00
Zach Gollwitzer
aa16807c6c Allow institutions on edit account form 2024-10-10 11:43:28 -04:00
Zach Gollwitzer
dce9adb534 Add institution back as hidden field on account form 2024-10-10 11:39:50 -04:00
Zach Gollwitzer
26bd655e4c Add value tab to investments 2024-10-10 11:35:10 -04:00
Zach Gollwitzer
5c7d2f2b01 Better import instructions, remove ambiguous field (#1284)
* Remove ambiguous institution field

* Add import instructions

* Fix system test

* Remove lint and i18n normalization checks in CI
2024-10-10 11:18:58 -04:00
Guillem Arias Fauste
90278630ed fix: amend inputs on loan, c.c., vehicle, and property partials (#1281)
* fix: use number inputs on partial loan and credit card form views

* amend vehicle partial

* amend property inputs

* fix lint

* Update app/views/accounts/accountables/_credit_card.html.erb

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

* Update app/views/accounts/accountables/_loan.html.erb

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

---------

Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>
Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
2024-10-10 10:45:17 -04:00
Guillem Arias Fauste
977da34efc fix: use correct delimiter on credit card zero values (#1280) 2024-10-10 10:14:05 -04:00
Zach Gollwitzer
6288139a41 Fix loan term display 2024-10-09 18:34:20 -04:00
Zach Gollwitzer
ff5408c131 Fix group trend color (#1277) 2024-10-09 18:20:45 -04:00
Zach Gollwitzer
0a303ccbd5 Fix currency formatting for 0 values (#1276)
* Fix currency formatting for 0 values

* Fix loan payment calculation for zero interest rate
2024-10-09 18:11:36 -04:00
Zach Gollwitzer
a2ab217925 Bug fixes for specialized account pages (#1275)
* Default for credit card fields

* Save institution on new account forms

* Fix property, vehicle, loan, credit card pages
2024-10-09 17:20:38 -04:00
Zach Gollwitzer
b4d0fdbe0d Link to CSV imports (#1273)
* Link to CSV imports

* Stale param
2024-10-09 15:22:08 -04:00
Zach Gollwitzer
4bfe47540d Basic trade and holdings view (#1271)
* Add trade view

* Lint fix

* Fix stale placeholder variable

* Add holding view
2024-10-09 14:59:18 -04:00
Josh Pigford
f5cb13b42f Padding tweak 2024-10-09 13:20:36 -05:00
Josh Pigford
3893060f8e Early access (#1272)
* Stubbing in early access

* Styling

* Title tweak

* Early access tweaks

Also removed the allow_browser helper as it tends to cause more headaches than we really care about at this point

* Lint
2024-10-09 13:17:58 -05:00
Zach Gollwitzer
54596d51f7 Fix account pill on dashboard (#1270) 2024-10-09 11:38:34 -04:00
Josh Pigford
7758f51be9 Support deprecated SELF_HOSTING_ENABLED variable for now 2024-10-09 09:56:22 -05:00
Josh Pigford
40c09279f3 i18n linter
I really need to remember to run these things before pushing
2024-10-09 09:16:15 -05:00
Josh Pigford
ad52207a25 Lint 2024-10-09 09:12:07 -05:00
Josh Pigford
a33ba11ce9 Update password_reset.html.erb 2024-10-09 09:06:41 -05:00
Josh Pigford
47a43a888c Make the password reset mailer a bit more...beefy 2024-10-09 09:03:21 -05:00
Josh Pigford
0afab5296c Email sender 2024-10-09 08:37:45 -05:00
Alter Lagos
0d7164af9b Set 3000 as the default web port (#1215)
Having by default `PORT=` only assigns to that variable `0`, which is
interpreted by puma to start the web app in a random port when `bin/dev`
is called.
2024-10-09 08:21:15 -04:00
Josh Pigford
597079dc8d Address faraday-multipart warning 2024-10-08 16:58:38 -05:00
Josh Pigford
fc91a34691 Change to mobile-web-app-capable meta tag 2024-10-08 16:56:30 -05:00
Zach Gollwitzer
fd941d714d Add loan and credit card views (#1268)
* Add loan and credit card views

* Lint fix

* Clean up overview card markup

* Lint fix

* Test fix
2024-10-08 17:16:37 -04:00
Josh Pigford
9263dd3bbe Allow promo codes in checkout 2024-10-08 15:19:23 -05:00
Josh Pigford
31f3ff6a16 Billing (#1269)
* Change env SELF_HOSTING_ENABLED to SELF_HOSTED

* Initial Stripe implementation

* Fix portal link

* Use webhook signatures

* Migrated to new Stripe gem conventions

Also updated resource routing

* Added faraday-multipart gem to resolve middleware notice

* Merge fix

* Merge fix

* Temporary upgrade prompt for early access

* Lint fix

* i18n fixes

* Remove catch-all rescue

* Update .env.example
2024-10-08 14:37:47 -05:00
Josh Pigford
41dff228e8 Crop profile images 2024-10-08 14:25:34 -05:00
Josh Pigford
78b0674052 Support for Cloudflare R2 2024-10-08 13:05:45 -05:00
Josh Pigford
3461182725 Ensure self hosted for invite code listing 2024-10-08 12:36:06 -05:00
Josh Pigford
59e4eff24a Lint 2024-10-08 12:30:28 -05:00
Josh Pigford
e70d3d1902 Generate multiple invites 2024-10-08 12:23:23 -05:00
Zach Gollwitzer
2f6479f058 Add empty states to account summary page (#1265)
* Add empty states to account summary page

* Liability icon fix

* Normalize translations

* Clean up modal styles

* Account color updates

* Lint fixes

* Test fix
2024-10-08 13:00:35 -04:00
Josh Pigford
ffd54e4065 Intercom integration (#1267)
* Intercom integration

Includes if/else statements for various ways to reach out. Also, github/discord icons updated to SVG.

* Update app/views/layouts/_sidebar.html.erb

Co-authored-by: Zach Gollwitzer <zach@maybe.co>
Signed-off-by: Josh Pigford <josh@joshpigford.com>

* Update app/views/pages/feedback.html.erb

Co-authored-by: Zach Gollwitzer <zach@maybe.co>
Signed-off-by: Josh Pigford <josh@joshpigford.com>

* Family = Company in Intercom

---------

Signed-off-by: Josh Pigford <josh@joshpigford.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-10-08 10:50:49 -05:00
Zach Gollwitzer
591d149da9 Finalize other assets and liabilities view (#1264) 2024-10-07 20:23:33 -04:00
Zach Gollwitzer
c397f1bd2b Hide infinity trend percentage changes (#1261) 2024-10-07 16:20:36 -04:00
Zach Gollwitzer
d2a6ab1e45 Hide currency for transfers (#1260) 2024-10-07 15:57:47 -04:00
dependabot[bot]
5e3a3b0b38 Bump webmock from 3.23.1 to 3.24.0 (#1252)
Bumps [webmock](https://github.com/bblimke/webmock) from 3.23.1 to 3.24.0.
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.23.1...v3.24.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-07 10:30:22 -04:00
dependabot[bot]
563db0f8eb Bump pagy from 9.0.9 to 9.1.0 (#1251)
Bumps [pagy](https://github.com/ddnexus/pagy) from 9.0.9 to 9.1.0.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/9.0.9...9.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-07 10:30:12 -04:00
dependabot[bot]
2dda598e8a Bump importmap-rails from 2.0.1 to 2.0.2 (#1255)
Bumps [importmap-rails](https://github.com/rails/importmap-rails) from 2.0.1 to 2.0.2.
- [Release notes](https://github.com/rails/importmap-rails/releases)
- [Commits](https://github.com/rails/importmap-rails/compare/v2.0.1...v2.0.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-07 10:29:09 -04:00
dependabot[bot]
388f8e4197 Bump ruby-lsp-rails from 0.3.16 to 0.3.18 (#1258)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.16 to 0.3.18.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.16...v0.3.18)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-07 10:28:56 -04:00
dependabot[bot]
1d56c67b4f Bump aws-sdk-s3 from 1.166.0 to 1.167.0 (#1253)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.166.0 to 1.167.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-07 10:24:10 -04:00
dependabot[bot]
f6619aa4e5 Bump tailwindcss-rails from 2.7.6 to 2.7.7 (#1256)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.7.6 to 2.7.7.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.7.6...v2.7.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-07 10:24:00 -04:00
dependabot[bot]
9453313f68 Bump propshaft from 1.0.1 to 1.1.0 (#1257)
Bumps [propshaft](https://github.com/rails/propshaft) from 1.0.1 to 1.1.0.
- [Release notes](https://github.com/rails/propshaft/releases)
- [Commits](https://github.com/rails/propshaft/compare/v1.0.1...v1.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-07 10:23:50 -04:00
Zach Gollwitzer
9ebcb6fc41 Bump to v0.1.0-alpha.18
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-10-04 15:09:58 -04:00
Zach Gollwitzer
24d3c0243f Handle missing weekend stock prices in sync process (#1242)
* Don't append missing prices if already known

* Add failing test

* Handle weekend stock prices

* Fix tests and gapfill logic
2024-10-04 14:19:45 -04:00
Zach Gollwitzer
e8d7ee3270 Add tag filtering (#1240) 2024-10-04 09:17:48 -04:00
Zach Gollwitzer
73d61fc990 Fix signage on transaction imports (#1236) 2024-10-03 14:59:24 -04:00
Zach Gollwitzer
1ffa13f3b3 Use DB for auth sessions (#1233)
* DB sessions

* Validations for profile image
2024-10-03 14:42:22 -04:00
Zach Gollwitzer
82c298307d Add formatting for EUR locales (#1231)
* Add formatting for EUR locales

* Fix formatting assertion for EUR in english locales
2024-10-03 10:25:38 -04:00
Zach Gollwitzer
ab40289eb4 Allow users to set preferred locale in settings and provide basic date and time localization support (#1226)
* Add basic date and time localization

* Normalize translations

* Localize transaction dates

* Removed unsupported Rails locales
2024-10-02 14:02:17 -04:00
Zach Gollwitzer
7fabca4679 Simplify self host settings controller (#1230) 2024-10-02 12:07:56 -04:00
Zach Gollwitzer
cb75c537fe Fix import migration (#1227) 2024-10-01 18:59:35 -04:00
Zach Gollwitzer
b1d2dc5e97 Add DB connection troubleshooting to self hosting guide
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-10-01 18:57:38 -04:00
Zach Gollwitzer
c3c0ab3530 Fix incorrect partial sync balance generation (#1223) 2024-10-01 13:15:24 -04:00
Alter Lagos
fa3b1e016c Sort currencies by name as a second order (#1216) 2024-10-01 10:48:39 -04:00
Zach Gollwitzer
398b246965 CSV Imports Overhaul (Transactions, Trades, Accounts, and Mint import support) (#1209)
* Remove stale 1.0 import logic and model

* Fresh start

* Checkpoint before removing nav

* First working prototype

* Add trade, account, and mint import flows

* Basic working version with tests

* System tests for each import type

* Clean up mappings flow

* Clean up PR, refactor stale code, tests

* Add back row validations

* Row validations

* Fix import job test

* Fix import navigation

* Fix mint import configuration form

* Currency preset for new accounts
2024-10-01 10:47:59 -04:00
Jestin Palamuttam
23786b444a Fix: Escape button not being handled on settings pages (#1210)
* fix: escape button handler

* feat: location logic for settings page

* fix: linting errors

* fix: linting error

* refactor: settings test
2024-09-30 17:28:15 -04:00
dependabot[bot]
edbf4eb3d6 Bump aws-sdk-s3 from 1.164.0 to 1.166.0 (#1217)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.164.0 to 1.166.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-30 10:19:12 -04:00
dependabot[bot]
367073f046 Bump propshaft from 1.0.0 to 1.0.1 (#1219)
Bumps [propshaft](https://github.com/rails/propshaft) from 1.0.0 to 1.0.1.
- [Release notes](https://github.com/rails/propshaft/releases)
- [Commits](https://github.com/rails/propshaft/compare/v1.0.0...v1.0.1)

---
updated-dependencies:
- dependency-name: propshaft
  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-09-30 10:19:03 -04:00
dependabot[bot]
2cb3d806d8 Bump sentry-rails from 5.19.0 to 5.20.1 (#1220)
Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 5.19.0 to 5.20.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.19.0...5.20.1)

---
updated-dependencies:
- dependency-name: sentry-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-09-30 10:18:47 -04:00
dependabot[bot]
3dd0aa2f37 Bump tailwindcss-rails from 2.7.4 to 2.7.6 (#1207)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.7.4 to 2.7.6.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.7.4...v2.7.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 17:08:29 -04:00
dependabot[bot]
2b9a7fdef3 Bump ruby-lsp-rails from 0.3.14 to 0.3.16 (#1200)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.14 to 0.3.16.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.14...v0.3.16)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 17:02:09 -04:00
dependabot[bot]
cb14ef7655 Bump aws-sdk-s3 from 1.162.0 to 1.164.0 (#1198)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.162.0 to 1.164.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 17:01:54 -04:00
dependabot[bot]
73ceebccc2 Bump selenium-webdriver from 4.24.0 to 4.25.0 (#1203)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.24.0 to 4.25.0.
- [Release notes](https://github.com/SeleniumHQ/selenium/releases)
- [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES)
- [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.24.0...selenium-4.25.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 17:01:40 -04:00
dependabot[bot]
17f29de773 Bump dotenv-rails from 3.1.2 to 3.1.4 (#1202)
Bumps [dotenv-rails](https://github.com/bkeepers/dotenv) from 3.1.2 to 3.1.4.
- [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.2...v3.1.4)

---
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-09-24 17:01:23 -04:00
dependabot[bot]
60fadc1d68 Bump puma from 6.4.2 to 6.4.3 (#1201)
Bumps [puma](https://github.com/puma/puma) from 6.4.2 to 6.4.3.
- [Release notes](https://github.com/puma/puma/releases)
- [Changelog](https://github.com/puma/puma/blob/master/History.md)
- [Commits](https://github.com/puma/puma/compare/v6.4.2...v6.4.3)

---
updated-dependencies:
- dependency-name: puma
  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-09-24 17:01:11 -04:00
dependabot[bot]
5eaf335c49 Bump faraday from 2.11.0 to 2.12.0 (#1197)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.11.0 to 2.12.0.
- [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.11.0...v2.12.0)

---
updated-dependencies:
- dependency-name: faraday
  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-09-24 17:01:01 -04:00
dependabot[bot]
be8f74b093 Bump turbo-rails from 2.0.7 to 2.0.10 (#1196)
Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.7 to 2.0.10.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.7...v2.0.10)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 17:00:47 -04:00
Zach Gollwitzer
b4b4e5df31 Finalize profile settings page for v0.2.0-alpha (#1194)
* Finalize profile settings page

* Translations

* Add ghost button to search menu
2024-09-20 15:56:21 -04:00
Zach Gollwitzer
5942ce7e3c Finalize transaction drawer, simplify money form helpers (#1191)
* Finalize transaction drawer, simplify money form helpers

* Fix money form errors

* Reusable disclosure helper, fix styles

* Final style tweaks
2024-09-20 08:38:19 -04:00
Zach Gollwitzer
730e58d763 Finish remaining transaction filters (#1189)
* Add type filters to transaction search

* Add amount filter
2024-09-17 10:38:02 -04:00
Tony Vincent
e06f0c76f9 Add error handling for vehicle and property account creation (#1179)
* add error handling for vehicle and property account creation

* Add required true for money_field

* Remove rescue in controllers
2024-09-17 10:37:09 -04:00
Zach Gollwitzer
aa6d755402 Fix styles on import modal (#1188)
* Fix styles on import modal

* Remove stale translations
2024-09-16 19:18:07 -04:00
dependabot[bot]
fd40111264 Bump propshaft from 0.9.1 to 1.0.0 (#1187)
Bumps [propshaft](https://github.com/rails/propshaft) from 0.9.1 to 1.0.0.
- [Release notes](https://github.com/rails/propshaft/releases)
- [Commits](https://github.com/rails/propshaft/compare/v0.9.1...v1.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 11:17:16 -04:00
dependabot[bot]
fc0bc1ac96 Bump ruby-lsp-rails from 0.3.13 to 0.3.14 (#1181)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.13 to 0.3.14.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.13...v0.3.14)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 10:24:05 -04:00
dependabot[bot]
b7e3c61d09 Bump good_job from 4.2.1 to 4.3.0 (#1183)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.2.1 to 4.3.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.2.1...v4.3.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>
2024-09-16 10:23:54 -04:00
dependabot[bot]
8181781570 Bump tailwindcss-rails from 2.7.3 to 2.7.4 (#1182)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.7.3 to 2.7.4.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.7.3...v2.7.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 10:14:06 -04:00
dependabot[bot]
5a5e27685a Bump turbo-rails from 2.0.6 to 2.0.7 (#1185)
Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.6...v2.0.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 10:13:49 -04:00
dependabot[bot]
cc1954b33b Bump aws-sdk-s3 from 1.160.0 to 1.162.0 (#1184)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.160.0 to 1.162.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 09:44:12 -04:00
dependabot[bot]
9bb9a062ac Bump pagy from 9.0.8 to 9.0.9 (#1186)
Bumps [pagy](https://github.com/ddnexus/pagy) from 9.0.8 to 9.0.9.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/9.0.8...9.0.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 09:43:59 -04:00
Zach Gollwitzer
52d3528361 Bump to v0.1.0-alpha.17
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-09-13 17:24:46 -04:00
Zach Gollwitzer
d3d9af8bce Add basic self hosted onboarding (#1177)
* Add basic self hosted onboarding

* Lint fix

* Normalize translations
2024-09-13 17:24:19 -04:00
Zach Gollwitzer
0149ca4ea1 Transaction page design fixes (#1176)
* Fix transaction summary spacing

* Fix search input padding

* Search and transfer design fixes
2024-09-13 15:43:16 -04:00
Zach Gollwitzer
30f7c120e1 Allow partial investment quantities (#1174) 2024-09-13 11:45:27 -04:00
Zach Gollwitzer
949d3d80fa Support multi-currency transfers (#1175) 2024-09-13 11:45:19 -04:00
Zach Gollwitzer
c28dd8f940 Omit trend if zero in sidebar (#1173)
* Omit trend if zero in sidebar

* Lint fix
2024-09-13 11:28:47 -04:00
Tony Vincent
277e4476d9 Fix missing sync_all_button partial (#1172)
* Fix missing sync_all_button partial

* Add missing translation

* Bring back partial

* Unify button text translation

* Add test
2024-09-13 11:19:20 -04:00
Zach Gollwitzer
b9341ac302 Add sync status and errors to account settings page (#1169) 2024-09-11 17:24:01 -04:00
Valentin Zwerschke
86741401c3 Fix text (#1168)
Signed-off-by: Valentin Zwerschke <vallezw@gmail.com>
2024-09-11 13:40:29 -04:00
Tony Vincent
edf44bec03 Add setting to disable new user registration on self-hosted instances (#1163)
* Add clipboard stimulus controller

* Add invite codes controller

* Setting to force invite code for new signups

* Fix erb linter

* Normalize keys

* Add POST /invite_codes

* Cleanup clipboard_controller.js

* Create invite codes on-demand

* Design changes

* Style alignment

* Update app/views/invite_codes/_invite_code.html.erb

Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
Signed-off-by: Tony Vincent <tonyvince7@gmail.com>

* Update app/views/invite_codes/_invite_code.html.erb

Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
Signed-off-by: Tony Vincent <tonyvince7@gmail.com>

* Split into individual forms

* Fix missing styles

* Update app/javascript/controllers/clipboard_controller.js

Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
Signed-off-by: Tony Vincent <tonyvince7@gmail.com>

* Fix test

---------

Signed-off-by: Tony Vincent <tonyvince7@gmail.com>
Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
2024-09-11 13:04:39 -04:00
Jestin Palamuttam
5178928b68 fix: html dialog not closing (#1167) 2024-09-11 09:24:38 -04:00
Zach Gollwitzer
ac0ff35360 Update empty account states on dashboard (#1166)
* Update empty account states on dashboard

* Translations
2024-09-10 17:17:10 -04:00
Zach Gollwitzer
04037b8943 Consolidate transaction menu items (#1164)
* Fix valuation frame issue

* Consolidate transactions menu items

* Translations
2024-09-10 14:45:08 -04:00
Zach Gollwitzer
cb13fd2245 Fix valuation frame issue (#1162) 2024-09-09 17:13:52 -04:00
Zach Gollwitzer
eebc07d75e Feedback page (#1160)
* Add feedback page

* Only show latest release on changelog

* Constrain changelog height

* Ignore sanitization warning for Github content

* Add cassette for Github release notes

* Lint fix
2024-09-09 16:54:56 -04:00
dependabot[bot]
c30c1b9698 Bump hotwire-livereload from 1.4.0 to 1.4.1 (#1157)
Bumps [hotwire-livereload](https://github.com/kirillplatonov/hotwire-livereload) from 1.4.0 to 1.4.1.
- [Release notes](https://github.com/kirillplatonov/hotwire-livereload/releases)
- [Commits](https://github.com/kirillplatonov/hotwire-livereload/compare/v1.4.0...v1.4.1)

---
updated-dependencies:
- dependency-name: hotwire-livereload
  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-09-09 07:57:30 -04:00
dependabot[bot]
d3971f9cee Bump inline_svg from 1.9.0 to 1.10.0 (#1156)
Bumps [inline_svg](https://github.com/jamesmartin/inline_svg) from 1.9.0 to 1.10.0.
- [Release notes](https://github.com/jamesmartin/inline_svg/releases)
- [Changelog](https://github.com/jamesmartin/inline_svg/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jamesmartin/inline_svg/compare/v1.9.0...v1.10.0)

---
updated-dependencies:
- dependency-name: inline_svg
  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-09-09 07:57:15 -04:00
dependabot[bot]
9e4b931612 Bump pg from 1.5.7 to 1.5.8 (#1158)
Bumps [pg](https://github.com/ged/ruby-pg) from 1.5.7 to 1.5.8.
- [Changelog](https://github.com/ged/ruby-pg/blob/master/History.md)
- [Commits](https://github.com/ged/ruby-pg/compare/v1.5.7...v1.5.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-09 07:56:05 -04:00
dependabot[bot]
b44da70836 Bump aws-sdk-s3 from 1.159.0 to 1.160.0 (#1159)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.159.0 to 1.160.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-09 07:55:51 -04:00
Zach Gollwitzer
0db75a019b Revert "Do not show registation link when REQUIRE_INVITE_CODE=true (#1148)" (#1155)
This reverts commit 9172eb931b.
2024-09-06 11:37:25 -04:00
dependabot[bot]
ee572d8d1f Bump selenium-webdriver from 4.23.0 to 4.24.0 (#1146)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.23.0 to 4.24.0.
- [Release notes](https://github.com/SeleniumHQ/selenium/releases)
- [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES)
- [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.23.0...selenium-4.24.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-06 09:25:22 -04:00
dependabot[bot]
33d007a07b Bump good_job from 4.2.0 to 4.2.1 (#1144)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.2.0 to 4.2.1.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.2.0...v4.2.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-06 09:25:11 -04:00
dependabot[bot]
3673ab8f03 Bump faraday from 2.10.1 to 2.11.0 (#1145)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.10.1 to 2.11.0.
- [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.10.1...v2.11.0)

---
updated-dependencies:
- dependency-name: faraday
  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-09-06 09:25:02 -04:00
dependabot[bot]
fb42c2ad43 Bump pagy from 9.0.6 to 9.0.8 (#1147)
Bumps [pagy](https://github.com/ddnexus/pagy) from 9.0.6 to 9.0.8.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/9.0.6...9.0.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-06 09:24:44 -04:00
Tony Vincent
9172eb931b Do not show registation link when REQUIRE_INVITE_CODE=true (#1148) 2024-09-06 08:52:26 -04:00
Josh Pigford
0c8cf7e217 Update .gitignore 2024-09-03 12:22:54 -04:00
Josh Pigford
0bbf7f82b7 .ai directory 2024-09-03 09:54:01 -04:00
Zach Gollwitzer
c05ee9b572 Remove unused settings temporarily (#1136) 2024-08-27 17:10:31 -04:00
Zach Gollwitzer
38c2b4670c Categories, tags, merchants, and menus improvements (#1135) 2024-08-27 17:06:41 -04:00
Zach Gollwitzer
f82ce59dad Fix merchants color picker (#1134)
* Fix merchants color picker

* Lint fixes
2024-08-26 19:18:27 -04:00
Zach Gollwitzer
166ed4b1ea Fix account transaction form resetting amount to 0 (#1133) 2024-08-26 19:10:17 -04:00
dependabot[bot]
0c0db44b7f Bump rails from 7.2.0 to 7.2.1 (#1130)
Bumps [rails](https://github.com/rails/rails) from 7.2.0 to 7.2.1.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](https://github.com/rails/rails/compare/v7.2.0...v7.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-08-26 10:38:46 -04:00
dependabot[bot]
cd254fd19b Bump aws-sdk-s3 from 1.158.0 to 1.159.0 (#1129)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.158.0 to 1.159.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 09:37:08 -04:00
dependabot[bot]
0d20be4905 Bump pagy from 9.0.5 to 9.0.6 (#1128)
Bumps [pagy](https://github.com/ddnexus/pagy) from 9.0.5 to 9.0.6.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/9.0.5...9.0.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 09:36:59 -04:00
Tony Vincent
cf861ccff9 Fix account sync when prices missing (#1127) 2024-08-26 09:36:27 -04:00
dependabot[bot]
525439e44d Bump vcr from 6.2.0 to 6.3.1 (#1131)
Bumps [vcr](https://github.com/vcr/vcr) from 6.2.0 to 6.3.1.
- [Release notes](https://github.com/vcr/vcr/releases)
- [Changelog](https://github.com/vcr/vcr/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vcr/vcr/compare/v6.2.0...v6.3.1)

---
updated-dependencies:
- dependency-name: vcr
  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-08-26 09:35:04 -04:00
Tony Vincent
e1efe97e6f Fix unable to create Deposit entries in investment portfolio (#1125)
* Fix unable to create Deposit entries in investment portfolio

* Add system test for deposit transaction
2024-08-25 17:48:46 -04:00
477 changed files with 31880 additions and 3653 deletions

64
.ai/cursorrules.md Normal file
View File

@@ -0,0 +1,64 @@
<!-- Copy this file to .cursorrules in the root of the project on your local machine if you'd like to use these rules with Cursor. -->
You are an expert in Ruby, Ruby on Rails, Postgres, Tailwind, Stimulus, Hotwire and Turbo and always use the latest stable versions of those technologies.
**Code Style and Structure**
- Write concise, technical Ruby code with accurate examples.
- Prefer iteration and modularization over code duplication.
- Use descriptive variable names with auxiliary verbs (e.g., is_loading, has_error).
- Structure files: models, controllers, views, helpers, services, jobs, mailers.
**Naming Conventions**
- Use snake_case for file names and directories (e.g., app/models/user_profile.rb).
- Use CamelCase for classes and modules (e.g., UserProfile).
**Ruby on Rails Usage**
- Use Rails conventions for MVC structure.
- Favor scopes over class methods for queries.
- Use strong parameters for mass assignment protection.
- Use partials to DRY up views.
**Syntax and Formatting**
- Use two spaces for indentation.
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
- Use descriptive method names and keep methods short.
**Commenting Code**
- Write clear, concise comments to explain the purpose of individual functions and methods.
- Use comments to describe the intent and functionality of complex logic.
- Avoid redundant comments that state the obvious.
**UI and Styling**
- Use Tailwind CSS for styling.
- Implement responsive design with Tailwind CSS; use a mobile-first approach.
- Use Stimulus for JavaScript behavior.
- Use Turbo for asynchronous actions and updates.
**Performance Optimization**
- Use eager loading to avoid N+1 queries.
- Cache expensive queries and partials where appropriate.
- Use background jobs for long-running tasks.
- Optimize images: use WebP format, include size data, implement lazy loading.
**Database Querying & Data Model Creation**
- Use ActiveRecord for data querying and model creation.
- Favor database constraints and indexes for data integrity and performance.
- Use migrations to manage schema changes.
**Key Conventions**
- Follow Rails best practices for RESTful routing.
- Optimize for performance and security.
- Use environment variables for configuration.
- Write tests for models, controllers, and features.
**AI Guidelines**
- Follow the users requirements carefully & to the letter.
- Confirm, then write code!
- Suggest solutions that I didn't think about—anticipate my needs
- Focus on readability over being performant.
- Fully implement all requested functionality.
- Leave NO todos, placeholders or missing pieces.
- Don't say things like "additional logic can be added here" — instead, add the logic.
- Be concise. Minimize any other prose.
- Consider new technologies and contrarian ideas, not just the conventional wisdom
- If I ask for adjustments to code, do not repeat all of my code unnecessarily. Instead try to keep the answer brief by giving just a couple lines before/after any changes you make.

View File

@@ -1,6 +1,6 @@
# Custom port config
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
PORT=
PORT=3000
# Exchange Rate API
# This is used to convert between different currencies in the app. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
@@ -15,7 +15,7 @@ SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_ENABLED=true
# Email Configuration
# Address that emails are sent from
EMAIL_SENDER=
# Database Configuration
@@ -36,8 +36,8 @@ SENTRY_DSN=
# This is useful for controlling who can sign up for your Maybe instance.
REQUIRE_INVITE_CODE=false
# Enables self hosting features
SELF_HOSTING_ENABLED=false
# Enables self hosting features (should be set to true for most folks)
SELF_HOSTED=true
# The hosting platform used to deploy the app (e.g. "render")
# `localhost` (or unset) is used for local development and testing
@@ -86,3 +86,19 @@ GITHUB_REPO_BRANCH=main
# S3_SECRET_ACCESS_KEY=
# S3_REGION= # defaults to `us-east-1` if not set
# S3_BUCKET=
#
# Cloudflare R2
# =============
# ACTIVE_STORAGE_SERVICE=cloudflare
# CLOUDFLARE_ACCOUNT_ID=
# CLOUDFLARE_ACCESS_KEY_ID=
# CLOUDFLARE_SECRET_ACCESS_KEY=
# CLOUDFLARE_BUCKET=
# ======================================================================================================
# Billing Module - responsible for handling billing
# ======================================================================================================
#
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

View File

@@ -52,9 +52,6 @@ jobs:
- name: Lint code for consistent style
run: bin/rubocop -f github
- name: Lint templates for consistent style
run: ./bin/erblint ./app/**/*.erb
test:
runs-on: ubuntu-latest
timeout-minutes: 10

1
.gitignore vendored
View File

@@ -59,3 +59,4 @@ compose-dev.yaml
gcp-storage-keyfile.json
coverage
.cursorrules

View File

@@ -3,7 +3,7 @@ source "https://rubygems.org"
ruby file: ".ruby-version"
# Rails
gem "rails", "~> 7.2.0"
gem "rails", "~> 7.2.1"
# Drivers
gem "pg", "~> 1.5"
@@ -38,6 +38,7 @@ gem "image_processing", ">= 1.2"
gem "bcrypt", "~> 3.1"
gem "faraday"
gem "faraday-retry"
gem "faraday-multipart"
gem "inline_svg"
gem "octokit"
gem "pagy"
@@ -45,6 +46,9 @@ gem "rails-settings-cached"
gem "tzinfo-data", platforms: %i[windows jruby]
gem "csv"
gem "redcarpet"
gem "stripe"
gem "intercom-rails"
gem "holidays"
group :development, :test do
gem "debug", platforms: %i[mri windows]

View File

@@ -1,6 +1,6 @@
GIT
remote: https://github.com/maybe-finance/lucide-rails.git
revision: 79d989593ee4ac6c50106ec5e4d2bd4ec8f5af87
revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0
specs:
lucide-rails (0.2.0)
railties (>= 4.1.0)
@@ -8,29 +8,29 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.0)
actionpack (= 7.2.0)
activesupport (= 7.2.0)
actioncable (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.0)
actionpack (= 7.2.0)
activejob (= 7.2.0)
activerecord (= 7.2.0)
activestorage (= 7.2.0)
activesupport (= 7.2.0)
actionmailbox (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
mail (>= 2.8.0)
actionmailer (7.2.0)
actionpack (= 7.2.0)
actionview (= 7.2.0)
activejob (= 7.2.0)
activesupport (= 7.2.0)
actionmailer (7.2.1)
actionpack (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activesupport (= 7.2.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.0)
actionview (= 7.2.0)
activesupport (= 7.2.0)
actionpack (7.2.1)
actionview (= 7.2.1)
activesupport (= 7.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.0)
actionpack (= 7.2.0)
activerecord (= 7.2.0)
activestorage (= 7.2.0)
activesupport (= 7.2.0)
actiontext (7.2.1)
actionpack (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.0)
activesupport (= 7.2.0)
actionview (7.2.1)
activesupport (= 7.2.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.0)
activesupport (= 7.2.0)
activejob (7.2.1)
activesupport (= 7.2.1)
globalid (>= 0.3.6)
activemodel (7.2.0)
activesupport (= 7.2.0)
activerecord (7.2.0)
activemodel (= 7.2.0)
activesupport (= 7.2.0)
activemodel (7.2.1)
activesupport (= 7.2.1)
activerecord (7.2.1)
activemodel (= 7.2.1)
activesupport (= 7.2.1)
timeout (>= 0.4.0)
activestorage (7.2.0)
actionpack (= 7.2.0)
activejob (= 7.2.0)
activerecord (= 7.2.0)
activesupport (= 7.2.0)
activestorage (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activesupport (= 7.2.1)
marcel (~> 1.0)
activesupport (7.2.0)
activesupport (7.2.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -78,24 +78,24 @@ GEM
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.965.0)
aws-sdk-core (3.201.5)
aws-partitions (1.985.0)
aws-sdk-core (3.209.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.88.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (1.94.0)
aws-sdk-core (~> 3, >= 3.207.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.158.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-s3 (1.167.0)
aws-sdk-core (~> 3, >= 3.207.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.9.1)
aws-sigv4 (1.10.0)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
bcrypt (3.1.20)
@@ -136,9 +136,9 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
docile (1.4.0)
dotenv (3.1.2)
dotenv-rails (3.1.2)
dotenv (= 3.1.2)
dotenv (3.1.4)
dotenv-rails (3.1.4)
dotenv (= 3.1.4)
railties (>= 6.1)
drb (2.2.1)
erb_lint (0.6.0)
@@ -153,10 +153,13 @@ GEM
tzinfo
faker (3.4.2)
i18n (>= 1.8.11, < 2)
faraday (2.10.1)
faraday-net_http (>= 2.0, < 3.2)
faraday (2.12.0)
faraday-net_http (>= 2.0, < 3.4)
json
logger
faraday-net_http (3.1.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.3.0)
net-http
faraday-retry (2.2.1)
faraday (~> 2.0)
@@ -171,20 +174,21 @@ GEM
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (4.2.0)
good_job (4.3.0)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
fugit (>= 1.11.0)
railties (>= 6.1.0)
thor (>= 1.0.0)
hashdiff (1.1.0)
hashdiff (1.1.1)
highline (3.0.1)
hotwire-livereload (1.4.0)
holidays (8.8.0)
hotwire-livereload (1.4.1)
actioncable (>= 6.0.0)
listen (>= 3.0.0)
railties (>= 6.0.0)
i18n (1.14.5)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.14)
activesupport (>= 4.0.2)
@@ -199,15 +203,17 @@ GEM
image_processing (1.13.0)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.0.1)
importmap-rails (2.0.2)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
inline_svg (1.9.0)
inline_svg (1.10.0)
activesupport (>= 3.0)
nokogiri (>= 1.6)
intercom-rails (1.0.1)
activesupport (> 4.0)
io-console (0.7.2)
irb (1.14.0)
irb (1.14.1)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
@@ -221,7 +227,7 @@ GEM
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.6.0)
logger (1.6.1)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
@@ -238,6 +244,7 @@ GEM
mocha (2.4.5)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
multipart-post (2.4.1)
net-http (0.4.1)
uri
net-imap (0.4.14)
@@ -265,22 +272,22 @@ GEM
octokit (9.1.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (9.0.5)
pagy (9.1.0)
parallel (1.25.1)
parser (3.3.4.0)
ast (~> 2.4.1)
racc
pg (1.5.7)
prism (0.30.0)
propshaft (0.9.1)
pg (1.5.8)
prism (1.1.0)
propshaft (1.1.0)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
railties (>= 7.0.0)
psych (5.1.2)
stringio
public_suffix (5.1.0)
puma (6.4.2)
public_suffix (6.0.1)
puma (6.4.3)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
@@ -292,20 +299,20 @@ GEM
rackup (2.1.0)
rack (>= 3)
webrick (~> 1.8)
rails (7.2.0)
actioncable (= 7.2.0)
actionmailbox (= 7.2.0)
actionmailer (= 7.2.0)
actionpack (= 7.2.0)
actiontext (= 7.2.0)
actionview (= 7.2.0)
activejob (= 7.2.0)
activemodel (= 7.2.0)
activerecord (= 7.2.0)
activestorage (= 7.2.0)
activesupport (= 7.2.0)
rails (7.2.1)
actioncable (= 7.2.1)
actionmailbox (= 7.2.1)
actionmailer (= 7.2.1)
actionpack (= 7.2.1)
actiontext (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activemodel (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
bundler (>= 1.15.0)
railties (= 7.2.0)
railties (= 7.2.1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -319,9 +326,9 @@ GEM
rails-settings-cached (2.9.4)
activerecord (>= 5.0.0)
railties (>= 5.0.0)
railties (7.2.0)
actionpack (= 7.2.0)
activesupport (= 7.2.0)
railties (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -330,18 +337,17 @@ GEM
rainbow (3.1.1)
rake (13.2.1)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
rb-inotify (0.11.1)
ffi (~> 1.0)
rbs (3.5.2)
rbs (3.6.1)
logger
rdoc (6.7.0)
psych (>= 4.0.0)
redcarpet (3.6.0)
regexp_parser (2.9.2)
reline (0.5.9)
reline (0.5.10)
io-console (~> 0.5)
rexml (3.3.4)
strscan
rexml (3.3.8)
rubocop (1.65.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
@@ -371,13 +377,13 @@ GEM
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.17.13)
ruby-lsp (0.19.1)
language_server-protocol (~> 3.17.0)
prism (>= 0.29.0, < 0.31)
prism (>= 1.1, < 2.0)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.3.13)
ruby-lsp (>= 0.17.12, < 0.18.0)
ruby-lsp-rails (0.3.18)
ruby-lsp (>= 0.19.0, < 0.20.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.2)
ffi (~> 1.12)
@@ -388,16 +394,16 @@ GEM
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
securerandom (0.3.1)
selenium-webdriver (4.23.0)
selenium-webdriver (4.25.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.19.0)
sentry-rails (5.20.1)
railties (>= 5.0)
sentry-ruby (~> 5.19.0)
sentry-ruby (5.19.0)
sentry-ruby (~> 5.20.1)
sentry-ruby (5.20.1)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
simplecov (0.22.0)
@@ -407,55 +413,55 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11528)
sorbet-runtime (0.5.11597)
stackprof (0.2.26)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.1)
strscan (3.1.0)
tailwindcss-rails (2.7.3)
stripe (13.0.0)
tailwindcss-rails (2.7.7)
railties (>= 7.0.0)
tailwindcss-rails (2.7.3-aarch64-linux)
tailwindcss-rails (2.7.7-aarch64-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.7.3-arm-linux)
tailwindcss-rails (2.7.7-arm-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.7.3-arm64-darwin)
tailwindcss-rails (2.7.7-arm64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.7.3-x86_64-darwin)
tailwindcss-rails (2.7.7-x86_64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.7.3-x86_64-linux)
tailwindcss-rails (2.7.7-x86_64-linux)
railties (>= 7.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
thor (1.3.1)
thor (1.3.2)
timeout (0.4.1)
turbo-rails (2.0.6)
turbo-rails (2.0.10)
actionpack (>= 6.0.0)
activejob (>= 6.0.0)
railties (>= 6.0.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0)
uri (0.13.0)
uri (0.13.1)
useragent (0.16.10)
vcr (6.2.0)
vcr (6.3.1)
base64
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.23.1)
webmock (3.24.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.1)
webrick (1.8.2)
websocket (1.2.11)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.17)
zeitwerk (2.6.18)
PLATFORMS
aarch64-linux
@@ -478,13 +484,16 @@ DEPENDENCIES
erb_lint
faker
faraday
faraday-multipart
faraday-retry
good_job
holidays
hotwire-livereload
i18n-tasks
image_processing (>= 1.2)
importmap-rails
inline_svg
intercom-rails
letter_opener
lucide-rails!
mocha
@@ -493,7 +502,7 @@ DEPENDENCIES
pg (~> 1.5)
propshaft
puma (>= 5.0)
rails (~> 7.2.0)
rails (~> 7.2.1)
rails-settings-cached
redcarpet
rubocop-rails-omakase
@@ -504,6 +513,7 @@ DEPENDENCIES
simplecov
stackprof
stimulus-rails
stripe
tailwindcss-rails
turbo-rails
tzinfo-data

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"><path fill="#5865f2" d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"/></svg>

After

Width:  |  Height:  |  Size: 764 B

View File

@@ -0,0 +1 @@
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>

After

Width:  |  Height:  |  Size: 963 B

View File

@@ -0,0 +1,160 @@
<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_iii_4725_68011)">
<path d="M1.66199 28.3573C3.33915 4.37286 8.83917 -0.408237 32.8236 1.26892L41.868 1.90136C65.8524 3.57851 70.6335 9.07854 68.9563 33.063L68.3239 42.1073C66.6467 66.0917 61.1467 70.8728 37.1623 69.1957L28.1179 68.5632C4.13349 66.8861 -0.647606 61.3861 1.02955 37.4016L1.66199 28.3573Z" fill="url(#paint0_linear_4725_68011)"/>
<path d="M1.66199 28.3573C3.33915 4.37286 8.83917 -0.408237 32.8236 1.26892L41.868 1.90136C65.8524 3.57851 70.6335 9.07854 68.9563 33.063L68.3239 42.1073C66.6467 66.0917 61.1467 70.8728 37.1623 69.1957L28.1179 68.5632C4.13349 66.8861 -0.647606 61.3861 1.02955 37.4016L1.66199 28.3573Z" fill="black" fill-opacity="0.7"/>
</g>
<path d="M2.82179 28.4384C3.23922 22.4687 3.89051 17.7733 4.98031 14.1012C6.06625 10.4421 7.56711 7.86966 9.64032 6.06745C11.7135 4.26524 14.4698 3.13701 18.2445 2.57088C22.0324 2.00274 26.7729 2.01127 32.7425 2.42871L41.7868 3.06115C47.7565 3.47859 52.452 4.12988 56.124 5.21968C59.7831 6.30562 62.3556 7.80648 64.1578 9.87969C65.96 11.9529 67.0882 14.7092 67.6544 18.4838C68.2225 22.2718 68.214 27.0122 67.7965 32.9819L67.1641 42.0262C66.7466 47.9959 66.0953 52.6913 65.0056 56.3634C63.9196 60.0225 62.4188 62.5949 60.3455 64.3971C58.2723 66.1994 55.516 67.3276 51.7414 67.8937C47.9534 68.4619 43.213 68.4533 37.2434 68.0359L28.199 67.4034C22.2294 66.986 17.5339 66.3347 13.8619 65.2449C10.2028 64.159 7.6303 62.6581 5.82808 60.5849C4.02587 58.5117 2.89764 55.7554 2.33151 51.9808C1.76337 48.1928 1.77191 43.4524 2.18934 37.4827L2.82179 28.4384Z" stroke="white" stroke-width="2.32525"/>
<path d="M2.82179 28.4384C3.23922 22.4687 3.89051 17.7733 4.98031 14.1012C6.06625 10.4421 7.56711 7.86966 9.64032 6.06745C11.7135 4.26524 14.4698 3.13701 18.2445 2.57088C22.0324 2.00274 26.7729 2.01127 32.7425 2.42871L41.7868 3.06115C47.7565 3.47859 52.452 4.12988 56.124 5.21968C59.7831 6.30562 62.3556 7.80648 64.1578 9.87969C65.96 11.9529 67.0882 14.7092 67.6544 18.4838C68.2225 22.2718 68.214 27.0122 67.7965 32.9819L67.1641 42.0262C66.7466 47.9959 66.0953 52.6913 65.0056 56.3634C63.9196 60.0225 62.4188 62.5949 60.3455 64.3971C58.2723 66.1994 55.516 67.3276 51.7414 67.8937C47.9534 68.4619 43.213 68.4533 37.2434 68.0359L28.199 67.4034C22.2294 66.986 17.5339 66.3347 13.8619 65.2449C10.2028 64.159 7.6303 62.6581 5.82808 60.5849C4.02587 58.5117 2.89764 55.7554 2.33151 51.9808C1.76337 48.1928 1.77191 43.4524 2.18934 37.4827L2.82179 28.4384Z" stroke="url(#paint1_linear_4725_68011)" stroke-width="2.32525"/>
<path d="M3.66933 28.4976C4.08541 22.5474 4.73164 17.9253 5.79481 14.343C6.85131 10.7831 8.28392 8.3723 10.1977 6.70866C12.1115 5.04503 14.6982 3.96188 18.3705 3.4111C22.0659 2.85684 26.7329 2.86017 32.6832 3.27625L41.7276 3.9087C47.6779 4.32478 52.2999 4.97101 55.8823 6.03418C59.4422 7.09068 61.8529 8.52329 63.5166 10.4371C65.1802 12.3509 66.2634 14.9376 66.8141 18.6098C67.3684 22.3053 67.3651 26.9723 66.949 32.9226L66.3165 41.967C65.9005 47.9172 65.2542 52.5393 64.1911 56.1216C63.1345 59.6815 61.7019 62.0923 59.7882 63.7559C57.8744 65.4196 55.2877 66.5027 51.6154 67.0535C47.92 67.6078 43.2529 67.6044 37.3026 67.1883L28.2583 66.5559C22.308 66.1398 17.6859 65.4936 14.1036 64.4304C10.5437 63.3739 8.13293 61.9413 6.4693 60.0275C4.80566 58.1137 3.72251 55.527 3.17173 51.8548C2.61747 48.1593 2.6208 43.4923 3.03688 37.542L3.66933 28.4976Z" stroke="url(#paint2_linear_4725_68011)" stroke-opacity="0.05" stroke-width="4.02448"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.20106 14.4635C5.15118 18.0011 4.50747 22.5866 4.09206 28.5272L3.45962 37.5716C3.04421 43.5122 3.04348 48.1426 3.59081 51.7919C4.13394 55.4131 5.1946 57.9152 6.78912 59.7495C8.38363 61.5838 10.7138 62.9824 14.2242 64.0242C17.7617 65.0741 22.3472 65.7178 28.2878 66.1332L37.3322 66.7656C43.2728 67.181 47.9033 67.1818 51.5525 66.6344C55.1738 66.0913 57.6759 65.0306 59.5101 63.4361C61.3444 61.8416 62.743 59.5115 63.7848 56.0011C64.8347 52.4635 65.4784 47.878 65.8938 41.9374L66.5262 32.893C66.9417 26.9524 66.9424 22.322 66.3951 18.6727C65.8519 15.0515 64.7913 12.5494 63.1968 10.7151C61.6022 8.88082 59.2721 7.48225 55.7617 6.44043C52.2241 5.39055 47.6387 4.74684 41.698 4.33143L32.6537 3.69899C26.713 3.28358 22.0826 3.28284 18.4333 3.83018C14.8121 4.3733 12.31 5.43397 10.4757 7.02849C8.64145 8.623 7.24288 10.9531 6.20106 14.4635ZM32.8236 1.26892C8.83917 -0.408237 3.33915 4.37286 1.66199 28.3573L1.02955 37.4016C-0.647606 61.3861 4.13349 66.8861 28.1179 68.5632L37.1623 69.1957C61.1467 70.8728 66.6467 66.0917 68.3239 42.1073L68.9563 33.063C70.6335 9.07854 65.8524 3.57851 41.868 1.90136L32.8236 1.26892Z" fill="url(#paint3_linear_4725_68011)"/>
<g filter="url(#filter1_ddii_4725_68011)">
<path d="M20.8165 43.8927L14.5692 43.4559C13.0888 43.3523 11.8006 44.5292 11.6919 46.0845C11.5831 47.6398 12.695 48.9845 14.1753 49.088L20.4227 49.5248C21.903 49.6284 23.1912 48.4515 23.3 46.8962C23.4087 45.3409 22.2968 43.9962 20.8165 43.8927Z" fill="#F23E94"/>
<path d="M14.5574 43.6244L20.8047 44.0612C22.1842 44.1577 23.2343 45.4143 23.1315 46.8844C23.0287 48.3546 21.814 49.4528 20.4344 49.3564L14.1871 48.9195C12.8076 48.823 11.7576 47.5664 11.8604 46.0963C11.9632 44.6261 13.1779 43.5279 14.5574 43.6244Z" stroke="url(#paint4_linear_4725_68011)" stroke-opacity="0.1" stroke-width="0.33782" stroke-linejoin="round"/>
<path d="M48.3652 51.4775L54.6125 51.9144C56.0928 52.0179 57.381 50.841 57.4898 49.2857C57.5985 47.7305 56.4866 46.3858 55.0063 46.2823L48.759 45.8454C47.2787 45.7419 45.9905 46.9188 45.8817 48.474C45.7729 50.0293 46.8848 51.374 48.3652 51.4775Z" fill="#F23E94"/>
<path d="M54.6243 51.7459L48.3769 51.309C46.9974 51.2126 45.9474 49.956 46.0502 48.4858C46.153 47.0157 47.3677 45.9174 48.7472 46.0139L54.9945 46.4508C56.3741 46.5472 57.4241 47.8038 57.3213 49.274C57.2185 50.7441 56.0038 51.8423 54.6243 51.7459Z" stroke="url(#paint5_linear_4725_68011)" stroke-opacity="0.1" stroke-width="0.33782" stroke-linejoin="round"/>
<path d="M37.3154 45.0271L32.2298 44.6714C30.7495 44.5679 29.4613 45.7448 29.3525 47.3001C29.2438 48.8553 30.3556 50.2001 31.836 50.3036L36.9215 50.6592C38.4019 50.7627 39.6901 49.5858 39.7988 48.0306C39.9076 46.4753 38.7957 45.1306 37.3154 45.0271Z" fill="#F23E94"/>
<path d="M32.218 44.8399L37.3036 45.1956C38.6831 45.292 39.7331 46.5486 39.6303 48.0188C39.5275 49.4889 38.3128 50.5872 36.9333 50.4907L31.8478 50.1351C30.4682 50.0386 29.4182 48.782 29.521 47.3119C29.6238 45.8417 30.8385 44.7435 32.218 44.8399Z" stroke="url(#paint6_linear_4725_68011)" stroke-opacity="0.1" stroke-width="0.33782" stroke-linejoin="round"/>
<path d="M46.5039 43.2046L52.5198 43.6253C54.0001 43.7288 55.2884 42.5519 55.3971 40.9967C55.5059 39.4414 54.394 38.0967 52.9136 37.9932L46.8977 37.5725C45.4174 37.469 44.1292 38.6459 44.0204 40.2011C43.9116 41.7564 45.0235 43.1011 46.5039 43.2046Z" fill="#6927DA"/>
<path d="M52.5316 43.4568L46.5156 43.0361C45.1361 42.9397 44.0861 41.6831 44.1889 40.2129C44.2917 38.7428 45.5064 37.6445 46.8859 37.741L52.9019 38.1617C54.2814 38.2582 55.3314 39.5148 55.2286 40.9849C55.1258 42.455 53.9111 43.5533 52.5316 43.4568Z" stroke="url(#paint7_linear_4725_68011)" stroke-opacity="0.1" stroke-width="0.33782" stroke-linejoin="round"/>
<path d="M23.7094 35.95L17.6934 35.5293C16.2131 35.4258 14.9249 36.6027 14.8161 38.158C14.7074 39.7133 15.8193 41.058 17.2996 41.1615L23.3155 41.5822C24.7959 41.6857 26.0841 40.5088 26.1928 38.9535C26.3016 37.3983 25.1897 36.0535 23.7094 35.95Z" fill="#6927DA"/>
<path d="M17.6817 35.6978L23.6976 36.1185C25.0771 36.215 26.1272 37.4716 26.0244 38.9417C25.9215 40.4119 24.7069 41.5101 23.3273 41.4137L17.3114 40.993C15.9319 40.8965 14.8818 39.6399 14.9846 38.1698C15.0874 36.6996 16.3021 35.6014 17.6817 35.6978Z" stroke="url(#paint8_linear_4725_68011)" stroke-opacity="0.1" stroke-width="0.33782" stroke-linejoin="round"/>
<path d="M39.8134 37.0582L30.8613 36.4322C29.381 36.3287 28.0927 37.5055 27.984 39.0608C27.8752 40.6161 28.9871 41.9608 30.4675 42.0643L39.4195 42.6903C40.8999 42.7938 42.1881 41.6169 42.2968 40.0617C42.4056 38.5064 41.2937 37.1617 39.8134 37.0582Z" fill="#6927DA"/>
<path d="M30.8495 36.6007L39.8016 37.2267C41.1811 37.3231 42.2311 38.5797 42.1283 40.0499C42.0255 41.52 40.8108 42.6183 39.4313 42.5218L30.4792 41.8958C29.0997 41.7994 28.0497 40.5428 28.1525 39.0726C28.2553 37.6025 29.47 36.5042 30.8495 36.6007Z" stroke="url(#paint9_linear_4725_68011)" stroke-opacity="0.1" stroke-width="0.33782" stroke-linejoin="round"/>
<path d="M32.3636 28.1666L20.9406 27.3679C19.4603 27.2643 18.1721 28.4412 18.0633 29.9965C17.9546 31.5518 19.0665 32.8965 20.5468 33L31.9698 33.7988C33.4501 33.9023 34.7383 32.7254 34.8471 31.1701C34.9558 29.6148 33.8439 28.2701 32.3636 28.1666Z" fill="#1570EF"/>
<path d="M20.9289 27.5363L32.3518 28.3351C33.7314 28.4316 34.7814 29.6882 34.6786 31.1583C34.5758 32.6285 33.3611 33.7267 31.9816 33.6303L20.5586 32.8315C19.179 32.735 18.129 31.4784 18.2318 30.0083C18.3346 28.5381 19.5493 27.4399 20.9289 27.5363Z" stroke="url(#paint10_linear_4725_68011)" stroke-opacity="0.1" stroke-width="0.33782" stroke-linejoin="round"/>
<path d="M39.7417 34.3403L50.4352 35.0881C51.9156 35.1916 53.2038 34.0147 53.3125 32.4594C53.4213 30.9042 52.3094 29.5595 50.8291 29.456L40.1356 28.7082C38.6552 28.6047 37.367 29.7816 37.2583 31.3368C37.1495 32.8921 38.2614 34.2368 39.7417 34.3403Z" fill="#1570EF"/>
<path d="M50.447 34.9196L39.7535 34.1718C38.374 34.0754 37.324 32.8188 37.4268 31.3486C37.5296 29.8785 38.7442 28.7802 40.1238 28.8767L50.8173 29.6245C52.1968 29.7209 53.2468 30.9775 53.144 32.4477C53.0412 33.9178 51.8265 35.0161 50.447 34.9196Z" stroke="url(#paint11_linear_4725_68011)" stroke-opacity="0.1" stroke-width="0.33782" stroke-linejoin="round"/>
<path d="M48.8251 21.1728L42.7957 20.7512C41.3154 20.6476 40.0272 21.8245 39.9184 23.3798C39.8097 24.9351 40.9216 26.2798 42.4019 26.3833L48.4312 26.8049C49.9116 26.9084 51.1998 25.7315 51.3085 24.1763C51.4173 22.621 50.3054 21.2763 48.8251 21.1728Z" fill="#22CCEE"/>
<path d="M42.784 20.9196L48.8133 21.3413C50.1928 21.4377 51.2428 22.6943 51.14 24.1645C51.0372 25.6346 49.8226 26.7329 48.443 26.6364L42.4137 26.2148C41.0342 26.1183 39.9841 24.8617 40.0869 23.3916C40.1897 21.9214 41.4044 20.8232 42.784 20.9196Z" stroke="url(#paint12_linear_4725_68011)" stroke-opacity="0.1" stroke-width="0.33782" stroke-linejoin="round"/>
<path d="M30.0984 19.8627L24.0691 19.4411C22.5887 19.3376 21.3005 20.5145 21.1918 22.0697C21.083 23.625 22.1949 24.9697 23.6752 25.0732L29.7046 25.4948C31.1849 25.5983 32.4731 24.4215 32.5819 22.8662C32.6906 21.3109 31.5787 19.9662 30.0984 19.8627Z" fill="#22CCEE"/>
<path d="M24.0573 19.6096L30.0866 20.0312C31.4661 20.1277 32.5162 21.3843 32.4134 22.8544C32.3106 24.3246 31.0959 25.4228 29.7163 25.3263L23.687 24.9047C22.3075 24.8083 21.2574 23.5517 21.3603 22.0815C21.4631 20.6114 22.6777 19.5131 24.0573 19.6096Z" stroke="url(#paint13_linear_4725_68011)" stroke-opacity="0.1" stroke-width="0.33782" stroke-linejoin="round"/>
</g>
<g opacity="0.23" filter="url(#filter2_f_4725_68011)">
<path d="M2.69258 40.4874L12.7122 44.9449L39.098 57.1213L66.9412 61.8859L45.5294 72.5984L-0.202764 68.4613L2.69258 40.4874Z" fill="#F24396"/>
</g>
<g opacity="0.23" filter="url(#filter3_f_4725_68011)">
<path d="M2.56821 1.97031L52.6272 -2.04293L69.5358 11.3492L56.9932 16.1074L23.2807 14.6892L2.56821 1.97031Z" fill="#22CCEE"/>
</g>
<defs>
<filter id="filter0_iii_4725_68011" x="0.72644" y="-2.61149" width="68.533" height="75.6876" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1.27762"/>
<feGaussianBlur stdDeviation="1.59703"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.196078 0 0 0 0 0.188235 0 0 0 0 0.219608 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_4725_68011"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="3.57731"/>
<feGaussianBlur stdDeviation="2.68298"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.321569 0 0 0 0 0.905882 0 0 0 0 1 0 0 0 0.6 0"/>
<feBlend mode="normal" in2="effect1_innerShadow_4725_68011" result="effect2_innerShadow_4725_68011"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-3.57731"/>
<feGaussianBlur stdDeviation="1.78866"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.933333 0 0 0 0 0.160784 0 0 0 0 0.509804 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="effect2_innerShadow_4725_68011" result="effect3_innerShadow_4725_68011"/>
</filter>
<filter id="filter1_ddii_4725_68011" x="1.54998" y="10.9893" width="66.0817" height="52.755" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1.6891"/>
<feGaussianBlur stdDeviation="5.0673"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.29135 0 0 0 0 0.0895476 0 0 0 0 0.654593 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4725_68011"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1.6891"/>
<feGaussianBlur stdDeviation="4.22275"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_4725_68011" result="effect2_dropShadow_4725_68011"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_4725_68011" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-1.6891"/>
<feGaussianBlur stdDeviation="0.844549"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="shape" result="effect3_innerShadow_4725_68011"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1.6891"/>
<feGaussianBlur stdDeviation="0.844549"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="effect3_innerShadow_4725_68011" result="effect4_innerShadow_4725_68011"/>
</filter>
<filter id="filter2_f_4725_68011" x="-23.4553" y="17.2348" width="113.649" height="78.6161" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="11.6263" result="effect1_foregroundBlur_4725_68011"/>
</filter>
<filter id="filter3_f_4725_68011" x="-20.6843" y="-25.2955" width="113.473" height="64.6555" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="11.6263" result="effect1_foregroundBlur_4725_68011"/>
</filter>
<linearGradient id="paint0_linear_4725_68011" x1="53.7772" y1="8.36942" x2="38.7315" y2="35.4937" gradientUnits="userSpaceOnUse">
<stop stop-color="#363636"/>
<stop offset="1" stop-color="#141414"/>
</linearGradient>
<linearGradient id="paint1_linear_4725_68011" x1="37.3458" y1="1.58514" x2="32.6401" y2="68.8795" gradientUnits="userSpaceOnUse">
<stop stop-color="#52EDFF"/>
<stop offset="0.274483" stop-color="#4361EE"/>
<stop offset="0.629793" stop-color="#7209B7"/>
<stop offset="1" stop-color="#F12980"/>
</linearGradient>
<linearGradient id="paint2_linear_4725_68011" x1="37.019" y1="6.25836" x2="33.4897" y2="56.7291" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint3_linear_4725_68011" x1="37.3458" y1="1.58514" x2="32.6401" y2="68.8795" gradientUnits="userSpaceOnUse">
<stop stop-color="#52EDFF"/>
<stop offset="0.274483" stop-color="#4361EE"/>
<stop offset="0.629793" stop-color="#7209B7"/>
<stop offset="1" stop-color="#F12980"/>
</linearGradient>
<linearGradient id="paint4_linear_4725_68011" x1="17.6928" y1="43.6743" x2="17.299" y2="49.3064" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint5_linear_4725_68011" x1="51.4888" y1="51.6959" x2="51.8826" y2="46.0638" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0"/>
<stop offset="1" stop-color="white"/>
</linearGradient>
<linearGradient id="paint6_linear_4725_68011" x1="34.7726" y1="44.8492" x2="34.3788" y2="50.4814" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint7_linear_4725_68011" x1="49.5118" y1="43.415" x2="49.9057" y2="37.7829" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0"/>
<stop offset="1" stop-color="white"/>
</linearGradient>
<linearGradient id="paint8_linear_4725_68011" x1="20.7014" y1="35.7397" x2="20.3076" y2="41.3718" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint9_linear_4725_68011" x1="35.3373" y1="36.7452" x2="34.9435" y2="42.3773" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint10_linear_4725_68011" x1="26.6521" y1="27.7672" x2="26.2583" y2="33.3994" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint11_linear_4725_68011" x1="45.0885" y1="34.7142" x2="45.4823" y2="29.0821" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0"/>
<stop offset="1" stop-color="white"/>
</linearGradient>
<linearGradient id="paint12_linear_4725_68011" x1="45.8104" y1="20.962" x2="45.4166" y2="26.5941" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0"/>
<stop offset="1" stop-color="white"/>
</linearGradient>
<linearGradient id="paint13_linear_4725_68011" x1="27.0837" y1="19.6519" x2="26.6899" y2="25.284" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -20,14 +20,14 @@
}
.form-field__label {
@apply block text-xs text-gray-500;
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
}
.form-field__input {
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
@apply focus:opacity-100 focus:outline-none focus:ring-0;
@apply placeholder-shown:opacity-50;
@apply disabled:opacity-50;
@apply disabled:text-gray-400;
}
.form-field__radio {
@@ -35,7 +35,7 @@
}
.form-field__submit {
@apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
@apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
}
input:checked+label+.toggle-switch-dot {
@@ -63,12 +63,16 @@
}
select[multiple="multiple"] option {
@apply p-2 rounded-md;
@apply py-2 rounded-md;
}
select[multiple="multiple"] option:checked {
@apply bg-gray-50;
@apply after:content-['\2713'] after:float-right after:text-gray-500;
@apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2;
}
select[multiple="multiple"] option:active,
select[multiple="multiple"] option:focus {
@apply bg-white;
}
.maybe-switch {
@@ -94,6 +98,26 @@
.tooltip {
@apply hidden absolute;
}
.btn {
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer;
}
.btn--primary {
@apply bg-gray-900 text-white hover:bg-gray-700 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
}
.btn--secondary {
@apply bg-gray-50 hover:bg-gray-100 text-gray-900;
}
.btn--outline {
@apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50;
}
.btn--ghost {
@apply border border-transparent text-gray-900 hover:bg-gray-50;
}
}
/* Small, single purpose classes that should take precedence over other styles */
@@ -110,4 +134,4 @@
.scrollbar::-webkit-scrollbar-thumb:hover {
background: #a6a6a6;
}
}
}

View File

@@ -25,7 +25,7 @@ class Account::EntriesController < ApplicationController
def destroy
@entry.destroy!
@entry.sync_account_later
redirect_back_or_to account_url(@entry.account), notice: t(".success")
redirect_to account_url(@entry.account), notice: t(".success")
end
private

View File

@@ -2,7 +2,7 @@ class Account::HoldingsController < ApplicationController
layout :with_sidebar
before_action :set_account
before_action :set_holding, only: :show
before_action :set_holding, only: %i[show destroy]
def index
@holdings = @account.holdings.current
@@ -11,6 +11,11 @@ class Account::HoldingsController < ApplicationController
def show
end
def destroy
@holding.destroy_holding_and_entries!
redirect_back_or_to account_holdings_path(@account)
end
private
def set_account

View File

@@ -2,6 +2,7 @@ class Account::TradesController < ApplicationController
layout :with_sidebar
before_action :set_account
before_action :set_entry, only: :update
def new
@entry = @account.entries.account_trades.new(entryable_attributes: {})
@@ -23,15 +24,36 @@ class Account::TradesController < ApplicationController
end
end
def update
@entry.update!(entry_params)
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
end
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def set_entry
@entry = @account.entries.find(params[:id])
end
def entry_params
params.require(:account_entry)
.permit(:type, :date, :qty, :ticker, :price, :amount, :currency, :transfer_account_id)
.permit(
:type, :date, :qty, :ticker, :price, :amount, :notes, :excluded, :currency, :transfer_account_id, :entryable_type,
entryable_attributes: [
:id,
:qty,
:ticker,
:price
]
)
.merge(account: @account)
end
end

View File

@@ -12,8 +12,7 @@ class Account::TransactionsController < ApplicationController
end
def update
@entry.update!(entry_params.merge(amount: amount))
@entry.sync_account_later
@entry.update!(entry_params)
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
@@ -34,23 +33,25 @@ class Account::TransactionsController < ApplicationController
def entry_params
params.require(:account_entry)
.permit(
:name, :date, :amount, :currency, :entryable_type,
:name, :date, :amount, :currency, :excluded, :notes, :entryable_type, :nature,
entryable_attributes: [
:id,
:notes,
:excluded,
:category_id,
:merchant_id,
{ tag_ids: [] }
]
)
end
).tap do |permitted_params|
nature = permitted_params.delete(:nature)
def amount
if params[:account_entry][:nature] == "income"
entry_params[:amount].to_d * -1
else
entry_params[:amount].to_d
end
if permitted_params[:amount]
amount_value = permitted_params[:amount].to_d
if nature == "income"
amount_value *= -1
end
permitted_params[:amount] = amount_value
end
end
end
end

View File

@@ -40,6 +40,6 @@ class Account::TransfersController < ApplicationController
end
def transfer_params
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :currency, :date, :name)
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name)
end
end

View File

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

View File

@@ -23,7 +23,10 @@ class AccountsController < ApplicationController
end
def new
@account = Account.new(accountable: Accountable.from_type(params[:type])&.new)
@account = Account.new(
accountable: Accountable.from_type(params[:type])&.new,
currency: Current.family.currency
)
@account.accountable.address = Address.new if @account.accountable.is_a?(Property)
@@ -38,14 +41,11 @@ class AccountsController < ApplicationController
end
def edit
@account.accountable.build_address if @account.accountable.is_a?(Property) && @account.accountable.address.blank?
end
def update
Account.transaction do
@account.update! account_params.except(:accountable_type, :balance)
@account.update_balance!(account_params[:balance]) if account_params[:balance]
end
@account.sync_later
@account.update_with_sync!(account_params)
redirect_back_or_to account_path(@account), notice: t(".success")
end
@@ -58,8 +58,6 @@ class AccountsController < ApplicationController
start_balance: account_params[:start_balance]
@account.sync_later
redirect_back_or_to account_path(@account), notice: t(".success")
rescue ActiveRecord::RecordInvalid => e
redirect_back_or_to accounts_path, alert: e.record.errors.full_messages.to_sentence
end
def destroy

View File

@@ -1,10 +1,7 @@
class ApplicationController < ActionController::Base
include AutoSync, Authentication, Invitable, SelfHostable
include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation
include Pagy::Backend
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
private
def with_sidebar

View File

@@ -2,6 +2,7 @@ module Authentication
extend ActiveSupport::Concern
included do
before_action :set_request_details
before_action :authenticate_user!
end
@@ -12,28 +13,30 @@ module Authentication
end
private
def authenticate_user!
if user = User.find_by(id: session[:user_id])
Current.user = user
if session_record = Session.find_by_id(cookies.signed[:session_token])
Current.session = session_record
else
redirect_to new_session_url
if self_hosted_first_login?
redirect_to new_registration_url
else
redirect_to new_session_url
end
end
end
def login(user)
Current.user = user
reset_session
session[:user_id] = user.id
set_last_login_at
def create_session_for(user)
session = user.sessions.create!
cookies.signed.permanent[:session_token] = { value: session.id, httponly: true }
session
end
def logout
Current.user = nil
reset_session
def self_hosted_first_login?
Rails.application.config.app_mode.self_hosted? && User.count.zero?
end
def set_last_login_at
Current.user.update(last_login_at: DateTime.now)
def set_request_details
Current.user_agent = request.user_agent
Current.ip_address = request.ip
end
end

View File

@@ -7,6 +7,10 @@ module Invitable
private
def invite_code_required?
ENV["REQUIRE_INVITE_CODE"] == "true"
self_hosted? ? Setting.require_invite_for_signup : ENV["REQUIRE_INVITE_CODE"] == "true"
end
def self_hosted?
Rails.application.config.app_mode.self_hosted?
end
end

View File

@@ -0,0 +1,13 @@
module Localize
extend ActiveSupport::Concern
included do
around_action :switch_locale
end
private
def switch_locale(&action)
locale = Current.family.try(:locale) || I18n.default_locale
I18n.with_locale(locale, &action)
end
end

View File

@@ -2,11 +2,15 @@ module SelfHostable
extend ActiveSupport::Concern
included do
helper_method :self_hosted?
helper_method :self_hosted?, :self_hosted_first_login?
end
private
def self_hosted?
Rails.configuration.app_mode.self_hosted?
end
def self_hosted_first_login?
self_hosted? && User.count.zero?
end
end

View File

@@ -0,0 +1,31 @@
module StoreLocation
extend ActiveSupport::Concern
included do
helper_method :previous_path
before_action :store_return_to
after_action :clear_previous_path
end
def previous_path
session[:return_to] || fallback_path
end
private
def store_return_to
if params[:return_to].present?
session[:return_to] = params[:return_to]
end
end
def clear_previous_path
if request.fullpath == session[:return_to]
session.delete(:return_to)
end
end
def fallback_path
root_path
end
end

View File

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

View File

@@ -0,0 +1,22 @@
class Import::CleansController < ApplicationController
layout "imports"
before_action :set_import
def show
redirect_to import_configuration_path(@import), alert: "Please configure your import before proceeding." unless @import.configured?
rows = @import.rows.ordered
if params[:view] == "errors"
rows = rows.reject { |row| row.valid? }
end
@pagy, @rows = pagy_array(rows, limit: params[:per_page] || "10")
end
private
def set_import
@import = Current.family.imports.find(params[:import_id])
end
end

View File

@@ -0,0 +1,25 @@
class Import::ConfigurationsController < ApplicationController
layout "imports"
before_action :set_import
def show
end
def update
@import.update!(import_params)
@import.generate_rows_from_csv
@import.reload.sync_mappings
redirect_to import_clean_path(@import), notice: "Import configured successfully."
end
private
def set_import
@import = Current.family.imports.find(params[:import_id])
end
def import_params
params.require(:import).permit(:date_col_label, :date_format, :name_col_label, :category_col_label, :tags_col_label, :amount_col_label, :signage_convention, :account_col_label, :notes_col_label, :entity_type_col_label)
end
end

View File

@@ -0,0 +1,14 @@
class Import::ConfirmsController < ApplicationController
layout "imports"
before_action :set_import
def show
redirect_to import_clean_path(@import), alert: "You have invalid data, please edit until all errors are resolved" unless @import.cleaned?
end
private
def set_import
@import = Current.family.imports.find(params[:import_id])
end
end

View File

@@ -0,0 +1,43 @@
class Import::MappingsController < ApplicationController
before_action :set_import
def update
mapping = @import.mappings.find(params[:id])
mapping.update! \
create_when_empty: create_when_empty,
mappable: mappable,
value: mapping_params[:value]
redirect_back_or_to import_confirm_path(@import)
end
private
def mapping_params
params.require(:import_mapping).permit(:type, :key, :mappable_id, :mappable_type, :value)
end
def set_import
@import = Current.family.imports.find(params[:import_id])
end
def mappable
return nil unless mappable_class.present?
@mappable ||= mappable_class.find_by(id: mapping_params[:mappable_id], family: Current.family)
end
def create_when_empty
return false unless mapping_class.present?
mapping_params[:mappable_id] == mapping_class::CREATE_NEW_KEY
end
def mappable_class
mapping_params[:mappable_type]&.constantize
end
def mapping_class
mapping_params[:type]&.constantize
end
end

View File

@@ -0,0 +1,24 @@
class Import::RowsController < ApplicationController
before_action :set_import_row
def update
@row.assign_attributes(row_params)
@row.save!(validate: false)
@row.sync_mappings
redirect_to import_row_path(@row.import, @row)
end
def show
end
private
def row_params
params.require(:import_row).permit(:type, :account, :date, :qty, :ticker, :price, :amount, :currency, :name, :category, :tags, :entity_type, :notes)
end
def set_import_row
@import = Current.family.imports.find(params[:import_id])
@row = @import.rows.find(params[:id])
end
end

View File

@@ -0,0 +1,47 @@
class Import::UploadsController < ApplicationController
layout "imports"
before_action :set_import
def show
end
def update
if csv_valid?(csv_str)
@import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep])
@import.save!(validate: false)
redirect_to import_configuration_path(@import), notice: "CSV uploaded successfully."
else
flash.now[:alert] = "Must be valid CSV with headers and at least one row of data"
render :show, status: :unprocessable_entity
end
end
private
def set_import
@import = Current.family.imports.find(params[:import_id])
end
def csv_str
@csv_str ||= upload_params[:csv_file]&.read || upload_params[:raw_file_str]
end
def csv_valid?(str)
require "csv"
begin
csv = CSV.parse(str || "", headers: true)
return false if csv.headers.empty?
return false if csv.count == 0
true
rescue CSV::MalformedCSVError
false
end
end
def upload_params
params.require(:import).permit(:raw_file_str, :csv_file, :col_sep)
end
end

View File

@@ -1,118 +1,44 @@
require "ostruct"
class ImportsController < ApplicationController
before_action :set_import, except: %i[index new create]
before_action :set_import, only: %i[show publish destroy]
def publish
@import.publish_later
redirect_to import_path(@import), notice: "Your import has started in the background."
end
def index
@imports = Current.family.imports
render layout: "with_sidebar"
render layout: with_sidebar
end
def new
account = Current.family.accounts.find_by(id: params[:account_id])
@import = Import.new account: account
end
def edit
end
def update
account = Current.family.accounts.find(params[:import][:account_id])
@import.update! account: account, col_sep: params[:import][:col_sep]
redirect_to load_import_path(@import), notice: t(".import_updated")
@pending_import = Current.family.imports.ordered.pending.first
end
def create
account = Current.family.accounts.find(params[:import][:account_id])
@import = Import.create! account: account, col_sep: params[:import][:col_sep]
import = Current.family.imports.create! import_params
redirect_to load_import_path(@import), notice: t(".import_created")
redirect_to import_upload_path(import)
end
def show
redirect_to import_confirm_path(@import), alert: "Please finalize your mappings before proceeding." unless @import.publishable?
end
def destroy
@import.destroy!
redirect_to imports_url, notice: t(".import_destroyed"), status: :see_other
end
@import.destroy
def load
end
def upload_csv
begin
@import.raw_file_str = import_params[:raw_file_str].read
rescue NoMethodError
flash.now[:alert] = "Please select a file to upload"
render :load, status: :unprocessable_entity and return
end
if @import.save
redirect_to configure_import_path(@import), notice: t(".import_loaded")
else
flash.now[:alert] = @import.errors.full_messages.to_sentence
render :load, status: :unprocessable_entity
end
end
def load_csv
if @import.update(import_params)
redirect_to configure_import_path(@import), notice: t(".import_loaded")
else
flash.now[:alert] = @import.errors.full_messages.to_sentence
render :load, status: :unprocessable_entity
end
end
def configure
unless @import.loaded?
redirect_to load_import_path(@import), alert: t(".invalid_csv")
end
end
def update_mappings
@import.update! import_params(@import.expected_fields.map(&:key))
redirect_to clean_import_path(@import), notice: t(".column_mappings_saved")
end
def clean
unless @import.loaded?
redirect_to load_import_path(@import), alert: t(".invalid_csv")
end
end
def update_csv
update_params = import_params[:csv_update]
@import.update_csv! \
row_idx: update_params[:row_idx],
col_idx: update_params[:col_idx],
value: update_params[:value]
render :clean
end
def confirm
unless @import.cleaned?
redirect_to clean_import_path(@import), alert: t(".invalid_data")
end
end
def publish
if @import.valid?
@import.publish_later
redirect_to imports_path, notice: t(".import_published")
else
flash.now[:error] = t(".invalid_data")
render :confirm, status: :unprocessable_entity
end
redirect_to imports_path, notice: "Your import has been deleted."
end
private
def set_import
@import = Current.family.imports.find(params[:id])
end
def import_params(permitted_mappings = nil)
params.require(:import).permit(:raw_file_str, column_mappings: permitted_mappings, csv_update: [ :row_idx, :col_idx, :value ])
def import_params
params.require(:import).permit(:type)
end
end

View File

@@ -23,6 +23,11 @@ class InstitutionsController < ApplicationController
redirect_to accounts_path, notice: t(".success")
end
def sync
@institution.sync
redirect_back_or_to accounts_path, notice: t(".success")
end
private
def institution_params

View File

@@ -0,0 +1,18 @@
class InviteCodesController < ApplicationController
before_action :ensure_self_hosted
def index
@invite_codes = InviteCode.all
end
def create
InviteCode.generate!
redirect_back_or_to invite_codes_path, notice: "Code generated"
end
private
def ensure_self_hosted
redirect_to root_path unless self_hosted?
end
end

View File

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

View File

@@ -1,5 +1,6 @@
class PagesController < ApplicationController
layout :with_sidebar
skip_before_action :authenticate_user!, only: %i[early_access]
layout :with_sidebar, except: %i[early_access]
include Filterable
@@ -25,18 +26,23 @@ class PagesController < ApplicationController
# TODO: Placeholders for trendlines
placeholder_series_data = 10.times.map do |i|
{ date: Date.current - i.days, value: Money.new(0) }
{ date: Date.current - i.days, value: Money.new(0, Current.family.currency) }
end
@investing_series = TimeSeries.new(placeholder_series_data)
end
def changelog
@releases_notes = Provider::Github.new.fetch_latest_releases_notes
@release_notes = Provider::Github.new.fetch_latest_release_notes
end
def feedback
end
def invites
def early_access
redirect_to root_path if self_hosted?
@invite_codes_count = InviteCode.count
@invite_code = InviteCode.order("RANDOM()").limit(1).first
render layout: false
end
end

View File

@@ -14,8 +14,7 @@ class PropertiesController < ApplicationController
end
def update
@account.update!(account_params)
@account.sync_later
@account.update_with_sync!(account_params)
redirect_to @account, notice: t(".success")
end
@@ -28,7 +27,7 @@ class PropertiesController < ApplicationController
def account_params
params.require(:account)
.permit(
:name, :balance, :start_date, :start_balance, :currency, :accountable_type,
:name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:year_built,

View File

@@ -17,7 +17,7 @@ class RegistrationsController < ApplicationController
if @user.save
Category.create_default_categories(@user.family)
login @user
@session = create_session_for(@user)
flash[:notice] = t(".success")
redirect_to root_path
else

View File

@@ -1,4 +1,5 @@
class SessionsController < ApplicationController
before_action :set_session, only: :destroy
skip_authentication only: %i[new create]
layout "auth"
@@ -8,7 +9,7 @@ class SessionsController < ApplicationController
def create
if user = User.authenticate_by(email: params[:email], password: params[:password])
login user
@session = create_session_for(user)
redirect_to root_path
else
flash.now[:alert] = t(".invalid_credentials")
@@ -17,7 +18,12 @@ class SessionsController < ApplicationController
end
def destroy
logout
@session.destroy
redirect_to root_path, notice: t(".logout_successful")
end
private
def set_session
@session = Current.user.sessions.find(params[:id])
end
end

View File

@@ -1,7 +1,2 @@
class Settings::BillingsController < SettingsController
def edit
end
def update
end
end

View File

@@ -1,71 +1,43 @@
class Settings::HostingsController < SettingsController
before_action :verify_hosting_mode
before_action :raise_if_not_self_hosted
def show
@synth_usage = Current.family.synth_usage
end
def update
if all_updates_valid?
hosting_params.keys.each do |key|
Setting.send("#{key}=", hosting_params[key].strip)
end
if hosting_params[:upgrades_setting].present?
mode = hosting_params[:upgrades_setting] == "manual" ? "manual" : "auto"
target = hosting_params[:upgrades_setting] == "commit" ? "commit" : "release"
redirect_to settings_hosting_path, notice: t(".success")
else
flash.now[:error] = @errors.first.message
render :show, status: :unprocessable_entity
end
end
def send_test_email
unless Setting.smtp_settings_populated?
flash[:alert] = t(".missing_smtp_setting_error")
render(:show, status: :unprocessable_entity)
return
Setting.upgrades_mode = mode
Setting.upgrades_target = target
end
begin
NotificationMailer.with(user: Current.user).test_email.deliver_now
rescue => _e
flash[:alert] = t(".error")
render :show, status: :unprocessable_entity
return
if hosting_params.key?(:render_deploy_hook)
Setting.render_deploy_hook = hosting_params[:render_deploy_hook]
end
if hosting_params.key?(:require_invite_for_signup)
Setting.require_invite_for_signup = hosting_params[:require_invite_for_signup]
end
if hosting_params.key?(:synth_api_key)
Setting.synth_api_key = hosting_params[:synth_api_key]
end
redirect_to settings_hosting_path, notice: t(".success")
rescue ActiveRecord::RecordInvalid => error
flash.now[:alert] = t(".failure")
render :show, status: :unprocessable_entity
end
private
def all_updates_valid?
@errors = ActiveModel::Errors.new(Setting)
hosting_params.keys.each do |key|
setting = Setting.new(var: key)
setting.value = hosting_params[key].strip
unless setting.valid?
@errors.merge!(setting.errors)
end
end
if hosting_params[:upgrades_mode] == "auto" && hosting_params[:render_deploy_hook].blank?
@errors.add(:render_deploy_hook, t("settings.hostings.update.render_deploy_hook_error"))
end
@errors.empty?
end
def hosting_params
permitted_params = params.require(:setting).permit(:render_deploy_hook, :upgrades_mode, :email_sender, :app_domain, :smtp_host, :smtp_port, :smtp_username, :smtp_password)
result = {}
result[:upgrades_mode] = permitted_params[:upgrades_mode] == "manual" ? "manual" : "auto" if permitted_params.key?(:upgrades_mode)
result[:render_deploy_hook] = permitted_params[:render_deploy_hook] if permitted_params.key?(:render_deploy_hook)
result[:upgrades_target] = permitted_params[:upgrades_mode] unless permitted_params[:upgrades_mode] == "manual" if permitted_params.key?(:upgrades_mode)
result.merge!(permitted_params.slice(:email_sender, :app_domain, :smtp_host, :smtp_port, :smtp_username, :smtp_password))
result
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :synth_api_key)
end
def verify_hosting_mode
head :not_found unless self_hosted?
def raise_if_not_self_hosted
raise "Settings not available on non-self-hosted instance" unless self_hosted?
end
end

View File

@@ -1,7 +0,0 @@
class Settings::NotificationsController < SettingsController
def edit
end
def update
end
end

View File

@@ -21,6 +21,6 @@ class Settings::PreferencesController < SettingsController
private
def preference_params
params.require(:user).permit(family_attributes: [ :id, :currency ])
params.require(:user).permit(family_attributes: [ :id, :currency, :locale ])
end
end

View File

@@ -23,7 +23,7 @@ class Settings::ProfilesController < SettingsController
def destroy
if Current.user.deactivate
logout
Current.session.destroy
redirect_to root_path, notice: t(".success")
else
redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence
@@ -31,7 +31,6 @@ class Settings::ProfilesController < SettingsController
end
private
def user_params
params.require(:user).permit(:first_name, :last_name, :profile_image,
family_attributes: [ :name, :id ])

View File

@@ -1,7 +0,0 @@
class Settings::SecuritiesController < SettingsController
def edit
end
def update
end
end

View File

@@ -0,0 +1,37 @@
class SubscriptionsController < ApplicationController
def new
client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
if Current.family.stripe_customer_id.blank?
customer = client.v1.customers.create(
email: Current.family.primary_user.email,
metadata: { family_id: Current.family.id }
)
Current.family.update(stripe_customer_id: customer.id)
end
session = client.v1.checkout.sessions.create({
customer: Current.family.stripe_customer_id,
line_items: [ {
price: ENV["STRIPE_PLAN_ID"],
quantity: 1
} ],
mode: "subscription",
allow_promotion_codes: true,
success_url: settings_billing_url,
cancel_url: settings_billing_url
})
redirect_to session.url, allow_other_host: true, status: :see_other
end
def show
client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
portal_session = client.v1.billing_portal.sessions.create(
customer: Current.family.stripe_customer_id,
return_url: settings_billing_url
)
redirect_to portal_session.url, allow_other_host: true, status: :see_other
end
end

View File

@@ -17,6 +17,9 @@ class TransactionsController < ApplicationController
@entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e|
if params[:account_id]
e.account = Current.family.accounts.find(params[:account_id])
e.currency = e.account.currency
else
e.currency = Current.family.currency
end
end
end
@@ -67,9 +70,6 @@ class TransactionsController < ApplicationController
redirect_back_or_to transactions_url, notice: t(".success")
end
def rules
end
private
def amount
@@ -93,7 +93,8 @@ class TransactionsController < ApplicationController
end
def search_params
params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: [])
params.fetch(:q, {})
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
end
def transaction_entry_params

View File

@@ -14,8 +14,7 @@ class VehiclesController < ApplicationController
end
def update
@account.update!(account_params)
@account.sync_later
@account.update_with_sync!(account_params)
redirect_to @account, notice: t(".success")
end
@@ -28,7 +27,7 @@ class VehiclesController < ApplicationController
def account_params
params.require(:account)
.permit(
:name, :balance, :start_date, :start_balance, :currency, :accountable_type,
:name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:make,

View File

@@ -0,0 +1,61 @@
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token, only: [ :stripe ]
skip_authentication
def stripe
webhook_body = request.body.read
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
begin
thin_event = client.parse_thin_event(webhook_body, sig_header, ENV["STRIPE_WEBHOOK_SECRET"])
event = client.v1.events.retrieve(thin_event.id)
case event.type
when /^customer\.subscription\./
handle_subscription_event(event)
when "customer.created", "customer.updated", "customer.deleted"
handle_customer_event(event)
else
Rails.logger.info "Unhandled event type: #{event.type}"
end
rescue JSON::ParserError
render json: { error: "Invalid payload" }, status: :bad_request
return
rescue Stripe::SignatureVerificationError
render json: { error: "Invalid signature" }, status: :bad_request
return
end
render json: { received: true }, status: :ok
end
private
def handle_subscription_event(event)
subscription = event.data.object
family = Family.find_by(stripe_customer_id: subscription.customer)
if family
family.update(
stripe_plan_id: subscription.plan.id,
stripe_subscription_status: subscription.status
)
else
Rails.logger.error "Family not found for Stripe customer ID: #{subscription.customer}"
end
end
def handle_customer_event(event)
customer = event.data.object
family = Family.find_by(stripe_customer_id: customer.id)
if family
family.update(stripe_customer_id: customer.id)
else
Rails.logger.error "Family not found for Stripe customer ID: #{customer.id}"
end
end
end

View File

@@ -1,4 +1,9 @@
module AccountsHelper
def summary_card(title:, &block)
content = capture(&block)
render "accounts/summary_card", title: title, content: content
end
def to_accountable_title(accountable)
accountable.model_name.human
end
@@ -31,6 +36,10 @@ module AccountsHelper
properties_path
when "Vehicle"
vehicles_path
when "Loan"
loans_path
when "CreditCard"
credit_cards_path
else
accounts_path
end
@@ -42,6 +51,10 @@ module AccountsHelper
property_path(account)
when "Vehicle"
vehicle_path(account)
when "Loan"
loan_path(account)
when "CreditCard"
credit_card_path(account)
else
account_path(account)
end
@@ -55,8 +68,10 @@ module AccountsHelper
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), route: account_transactions_path(account) }
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), route: account_trades_path(account) }
return [ value_tab ] if account.other_asset? || account.other_liability?
return [ overview_tab, value_tab ] if account.property? || account.vehicle?
return [ holdings_tab, cash_tab, trades_tab ] if account.investment?
return [ holdings_tab, cash_tab, trades_tab, value_tab ] if account.investment?
return [ overview_tab, value_tab, transactions_tab ] if account.loan? || account.credit_card?
[ value_tab, transactions_tab ]
end
@@ -69,6 +84,11 @@ module AccountsHelper
tab || available_tabs.first
end
def account_groups(period: nil)
assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
[ assets.children, liabilities.children ].flatten
end
private
def class_mapping(accountable_type)

View File

@@ -57,9 +57,9 @@ module ApplicationHelper
render partial: "shared/drawer", locals: { content: content }
end
def account_groups(period: nil)
assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
[ assets.children, liabilities.children ].flatten
def disclosure(title, &block)
content = capture &block
render partial: "shared/disclosure", locals: { title: title, content: content }
end
def sidebar_link_to(name, path, options = {})
@@ -137,14 +137,18 @@ module ApplicationHelper
end
def format_money(number_or_money, options = {})
return nil unless number_or_money
money = Money.new(number_or_money)
options.reverse_merge!(money.default_format_options)
options.reverse_merge!(money.format_options(I18n.locale))
number_to_currency(money.amount, options)
end
def format_money_without_symbol(number_or_money, options = {})
return nil unless number_or_money
money = Money.new(number_or_money)
options.reverse_merge!(money.default_format_options)
options.reverse_merge!(money.format_options(I18n.locale))
ActiveSupport::NumberHelper.number_to_delimited(money.amount.round(options[:precision] || 0), { delimiter: options[:delimiter], separator: options[:separator] })
end

View File

@@ -1,6 +0,0 @@
module AuthMessagesHelper
def auth_messages(form = nil)
render "shared/auth_messages", flash: flash,
errors: form&.object&.errors&.full_messages || []
end
end

View File

@@ -10,11 +10,6 @@ module FormsHelper
render partial: "shared/modal_form", locals: { title:, subtitle:, content: }
end
def form_field_tag(options = {}, &block)
options[:class] = [ "form-field", options[:class] ].compact.join(" ")
tag.div(**options, &block)
end
def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: false)
form.label name, for: form.field_id(name, value), class: "group has-[:disabled]:cursor-not-allowed" do
concat radio_tab_contents(label:, icon:)
@@ -27,55 +22,11 @@ module FormsHelper
form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" })
end
def money_with_currency_field(form, money_method, options = {})
render partial: "shared/money_field", locals: {
form: form,
money_method: money_method,
default_currency: options[:default_currency] || "USD",
disable_currency: options[:disable_currency] || false,
hide_currency: options[:hide_currency] || false,
label: options[:label] || "Amount"
}
end
def money_field(form, method, options = {})
value = form.object ? form.object.send(method) : nil
currency = value&.currency || Money::Currency.new(options[:default_currency] || "USD")
# See "Monetizable" concern
money_amount_method = method.to_s.chomp("_money").to_sym
money_options = {
value: value&.amount,
placeholder: "100",
min: -99999999999999,
max: 99999999999999,
step: currency.step
}
merged_options = options.merge(money_options)
form.number_field money_amount_method, merged_options
end
def currency_select_full(form, method, options = {}, html_options = {}, &block)
choices = currencies_for_select.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }
form.select method, choices, options, html_options, &block
end
def currency_select(form, method, options = {}, html_options = {}, &block)
choices = currencies_for_select.map(&:iso_code)
form.select method, choices, options, html_options, &block
def currencies_for_select
Money::Currency.all_instances.sort_by { |currency| [ currency.priority, currency.name ] }
end
private
def currencies_for_select
Money::Currency.all_instances
.sort_by(&:priority)
end
def radio_tab_contents(label:, icon:)
tag.div(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
concat lucide_icon(icon, class: "w-5 h-5")

View File

@@ -1,19 +1,63 @@
module ImportsHelper
def table_corner_class(row_idx, col_idx, rows, cols)
return "rounded-tl-xl" if row_idx == 0 && col_idx == 0
return "rounded-tr-xl" if row_idx == 0 && col_idx == cols.size - 1
return "rounded-bl-xl" if row_idx == rows.size - 1 && col_idx == 0
return "rounded-br-xl" if row_idx == rows.size - 1 && col_idx == cols.size - 1
""
def mapping_label(mapping_class)
{
"Import::AccountTypeMapping" => "Account Type",
"Import::AccountMapping" => "Account",
"Import::CategoryMapping" => "Category",
"Import::TagMapping" => "Tag"
}.fetch(mapping_class.name)
end
def nav_steps(import = Import.new)
[
{ name: "Select", complete: import.persisted?, path: import.persisted? ? edit_import_path(import) : new_import_path },
{ name: "Import", complete: import.loaded?, path: import.persisted? ? load_import_path(import) : nil },
{ name: "Setup", complete: import.configured?, path: import.persisted? ? configure_import_path(import) : nil },
{ name: "Clean", complete: import.cleaned?, path: import.persisted? ? clean_import_path(import) : nil },
{ name: "Confirm", complete: import.complete?, path: import.persisted? ? confirm_import_path(import) : nil }
]
def import_col_label(key)
{
date: "Date",
amount: "Amount",
name: "Name",
currency: "Currency",
category: "Category",
tags: "Tags",
account: "Account",
notes: "Notes",
qty: "Quantity",
ticker: "Ticker",
price: "Price",
entity_type: "Type"
}[key]
end
def dry_run_resource(key)
map = {
transactions: DryRunResource.new(label: "Transactions", icon: "credit-card", text_class: "text-cyan-500", bg_class: "bg-cyan-500/5"),
accounts: DryRunResource.new(label: "Accounts", icon: "layers", text_class: "text-orange-500", bg_class: "bg-orange-500/5"),
categories: DryRunResource.new(label: "Categories", icon: "shapes", text_class: "text-blue-500", bg_class: "bg-blue-500/5"),
tags: DryRunResource.new(label: "Tags", icon: "tags", text_class: "text-violet-500", bg_class: "bg-violet-500/5")
}
map[key]
end
def permitted_import_configuration_path(import)
if permitted_import_types.include?(import.type.underscore)
"import/configurations/#{import.type.underscore}"
else
raise "Unknown import type: #{import.type}"
end
end
def cell_class(row, field)
base = "text-sm focus:ring-gray-900 focus:border-gray-900 w-full max-w-full disabled:text-gray-400"
row.valid? # populate errors
border = row.errors.key?(field) ? "border-red-500" : "border-transparent"
[ base, border ].join(" ")
end
private
def permitted_import_types
%w[transaction_import trade_import account_import mint_import]
end
DryRunResource = Struct.new(:label, :icon, :text_class, :bg_class, keyword_init: true)
end

View File

@@ -1,12 +1,12 @@
module MenusHelper
def contextual_menu(&block)
tag.div class: "relative cursor-pointer", data: { controller: "menu" } do
tag.div data: { controller: "menu" } do
concat contextual_menu_icon
concat contextual_menu_content(&block)
end
end
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: nil)
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal)
link_to url, class: "flex items-center rounded-lg text-gray-900 hover:bg-gray-50 py-2 px-3 gap-2", data: { turbo_frame: } do
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-gray-500"))
concat(tag.span(label, class: "text-sm"))
@@ -25,13 +25,14 @@ module MenusHelper
private
def contextual_menu_icon
tag.button class: "flex hover:bg-gray-100 p-2 rounded", data: { menu_target: "button" } do
tag.button class: "flex hover:bg-gray-100 p-2 rounded cursor-pointer", data: { menu_target: "button" } do
lucide_icon "more-horizontal", class: "w-5 h-5 text-gray-500"
end
end
def contextual_menu_content(&block)
tag.div class: "absolute z-10 top-10 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden", data: { menu_target: "content" } do
tag.div class: "z-50 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden",
data: { menu_target: "content" } do
capture(&block)
end
end

View File

@@ -0,0 +1,2 @@
module Settings::BillingHelper
end

View File

@@ -1,14 +1,47 @@
module SettingsHelper
def next_setting(title, path)
render partial: "settings/nav_link_large", locals: { path: path, direction: "next", title: title }
end
SETTINGS_ORDER = [
{ name: I18n.t("settings.nav.profile_label"), path: :settings_profile_path },
{ name: I18n.t("settings.nav.preferences_label"), path: :settings_preferences_path },
{ name: I18n.t("settings.nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
{ name: I18n.t("settings.nav.billing_label"), path: :settings_billing_path },
{ name: I18n.t("settings.nav.accounts_label"), path: :accounts_path },
{ name: I18n.t("settings.nav.tags_label"), path: :tags_path },
{ name: I18n.t("settings.nav.categories_label"), path: :categories_path },
{ name: I18n.t("settings.nav.merchants_label"), path: :merchants_path },
{ name: I18n.t("settings.nav.imports_label"), path: :imports_path },
{ name: I18n.t("settings.nav.whats_new_label"), path: :changelog_path },
{ name: I18n.t("settings.nav.feedback_label"), path: :feedback_path }
]
def previous_setting(title, path)
render partial: "settings/nav_link_large", locals: { path: path, direction: "previous", title: title }
def adjacent_setting(current_path, offset)
visible_settings = SETTINGS_ORDER.select { |setting| setting[:condition].nil? || send(setting[:condition]) }
current_index = visible_settings.index { |setting| send(setting[:path]) == current_path }
return nil unless current_index
adjacent_index = current_index + offset
return nil if adjacent_index < 0 || adjacent_index >= visible_settings.size
adjacent = visible_settings[adjacent_index]
render partial: "settings/nav_link_large", locals: {
path: send(adjacent[:path]),
direction: offset > 0 ? "next" : "previous",
title: adjacent[:name]
}
end
def settings_section(title:, subtitle: nil, &block)
content = capture(&block)
render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content }
end
def settings_nav_footer
previous_setting = adjacent_setting(request.path, -1)
next_setting = adjacent_setting(request.path, 1)
content_tag :div, class: "flex justify-between gap-4" do
concat(previous_setting)
concat(next_setting)
end
end
end

View File

@@ -6,53 +6,69 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
text_field_helpers.each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {})
input_html = label_html(method, options) + super(method, merged_options(options))
input_html = apply_form_field_wrapper(input_html) unless options[:inline]
input_html
merged_options = { class: "form-field__input" }.merge(options)
label = build_label(method, options)
field = super(method, merged_options)
build_styled_field(label, field, merged_options)
end
RUBY_EVAL
end
def radio_button(method, tag_value, options = {})
super(method, tag_value, merged_options(options, "form-field__radio"))
merged_options = { class: "form-field__radio" }.merge(options)
super(method, tag_value, merged_options)
end
def select(method, choices, options = {}, html_options = {})
input_html = label_html(method, options) + super(method, choices, options, merged_options(html_options))
input_html = apply_form_field_wrapper(input_html, class: "pr-0") unless options[:inline]
input_html
merged_html_options = { class: "form-field__input" }.merge(html_options)
label = build_label(method, options)
field = super(method, choices, options, merged_html_options)
build_styled_field(label, field, options, remove_padding_right: true)
end
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
input_html = label_html(method, options) + super(method, collection, value_method, text_method, options, merged_options(html_options))
input_html = apply_form_field_wrapper(input_html, class: "pr-0") unless options[:inline]
input_html
merged_html_options = { class: "form-field__input" }.merge(html_options)
label = build_label(method, options)
field = super(method, collection, value_method, text_method, options, merged_html_options)
build_styled_field(label, field, options, remove_padding_right: true)
end
def money_field(amount_method, options = {})
@template.render partial: "shared/money_field", locals: {
form: self,
amount_method:,
currency_method: options[:currency_method] || :currency,
**options
}
end
def submit(value = nil, options = {})
merged_options = { class: "btn btn--primary w-full" }.merge(options)
value, options = nil, value if value.is_a?(Hash)
super(value, merged_options(options, "form-field__submit"))
super(value, merged_options)
end
private
def apply_form_field_wrapper(input_html, **options)
@template.form_field_tag(**options) do
input_html
def build_styled_field(label, field, options, remove_padding_right: false)
if options[:inline]
label + field
else
@template.tag.div class: [ "form-field", options[:container_class], ("pr-0" if remove_padding_right) ] do
label + field
end
end
end
def merged_options(options, default_class = "form-field__input")
combined_classes = options.fetch(:class, "") + " #{default_class}"
style_options = { class: combined_classes }
non_custom_options = options.except(:class, :label, :inline)
style_options.merge(non_custom_options)
end
def label_html(method, options)
return label(method, class: "form-field__label") if options[:label] == true
def build_label(method, options)
return "".html_safe unless options[:label]
return label(method, class: "form-field__label") if options[:label] == true
label(method, options[:label], class: "form-field__label")
end
end

View File

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

View File

@@ -1,12 +1,13 @@
module TransactionsHelper
def transaction_search_filters
[
{ key: "account_filter", name: "Account", icon: "layers" },
{ key: "date_filter", name: "Date", icon: "calendar" },
{ key: "type_filter", name: "Type", icon: "shapes" },
{ key: "amount_filter", name: "Amount", icon: "hash" },
{ key: "category_filter", name: "Category", icon: "tag" },
{ key: "merchant_filter", name: "Merchant", icon: "store" }
{ key: "account_filter", icon: "layers" },
{ key: "date_filter", icon: "calendar" },
{ key: "type_filter", icon: "tag" },
{ key: "amount_filter", icon: "hash" },
{ key: "category_filter", icon: "shapes" },
{ key: "tag_filter", icon: "tags" },
{ key: "merchant_filter", icon: "store" }
]
end

View File

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

View File

@@ -1,21 +1,21 @@
import {Controller} from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="bulk-select"
export default class extends Controller {
static targets = ["row", "group", "selectionBar", "selectionBarText", "bulkEditDrawerTitle"]
static values = {
resource: String,
selectedIds: {type: Array, default: []}
selectedIds: { type: Array, default: [] }
}
connect() {
document.addEventListener("turbo:load", this.#updateView)
document.addEventListener("turbo:load", this._updateView)
this.#updateView()
this._updateView()
}
disconnect() {
document.removeEventListener("turbo:load", this.#updateView)
document.removeEventListener("turbo:load", this._updateView)
}
bulkEditDrawerTitleTargetConnected(element) {
@@ -63,7 +63,7 @@ export default class extends Controller {
}
selectedIdsValueChanged() {
this.#updateView()
this._updateView()
}
#addHiddenFormInputsForSelectedIds(form, paramName, transactionIds) {
@@ -101,7 +101,7 @@ export default class extends Controller {
this.selectedIdsValue = this.rowTargets.map(t => t.dataset.id)
}
#updateView = () => {
_updateView = () => {
this.#updateSelectionBar()
this.#updateGroups()
this.#updateRows()

View File

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

View File

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

View File

@@ -1,98 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "preview", "submit", "filename", "filesize"]
static values = {
acceptedTypes: Array, // ["text/csv", "application/csv", ".csv"]
acceptedExtension: String, // "csv"
unacceptableTypeLabel: String, // "Only CSV files are allowed."
};
connect() {
this.submitTarget.disabled = true
}
addFile(event) {
const file = event.target.files[0]
this._fileAdded(file)
}
dragover(event) {
event.preventDefault()
event.stopPropagation()
event.currentTarget.classList.add("bg-gray-100")
}
dragleave(event) {
event.preventDefault()
event.stopPropagation()
event.currentTarget.classList.remove("bg-gray-100")
}
drop(event) {
event.preventDefault()
event.stopPropagation()
event.currentTarget.classList.remove("bg-gray-100")
const file = event.dataTransfer.files[0]
if (file && this._formatAcceptable(file)) {
this._setFileInput(file);
this._fileAdded(file)
} else {
this.previewTarget.classList.add("text-red-500")
this.previewTarget.textContent = this.unacceptableTypeLabelValue
}
}
// Private
_fetchFileSize(size) {
let fileSize = '';
if (size < 1024 * 1024) {
fileSize = (size / 1024).toFixed(2) + ' KB'; // Convert bytes to KB
} else {
fileSize = (size / (1024 * 1024)).toFixed(2) + ' MB'; // Convert bytes to MB
}
return fileSize;
}
_fileAdded(file) {
const fileSizeLimit = 5 * 1024 * 1024 // 5MB
if (file) {
if (file.size > fileSizeLimit) {
this.previewTarget.classList.add("text-red-500")
this.previewTarget.textContent = this.unacceptableTypeLabelValue
return
}
this.submitTarget.classList.remove([
"bg-alpha-black-25",
"text-gray",
"cursor-not-allowed",
]);
this.submitTarget.classList.add(
"bg-gray-900",
"text-white",
"cursor-pointer",
);
this.submitTarget.disabled = false;
this.previewTarget.innerHTML = document.querySelector("#template-preview").innerHTML;
this.previewTarget.classList.remove("text-red-500")
this.previewTarget.classList.add("text-gray-900")
this.filenameTarget.textContent = file.name;
this.filesizeTarget.textContent = this._fetchFileSize(file.size);
}
}
_formatAcceptable(file) {
const extension = file.name.split('.').pop().toLowerCase()
return this.acceptedTypesValue.includes(file.type) || extension === this.acceptedExtensionValue
}
_setFileInput(file) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
this.inputTarget.files = dataTransfer.files;
}
}

View File

@@ -1,61 +1,57 @@
import { Controller } from "@hotwired/stimulus";
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';
/**
* A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms.
*
* - If you need a form-enabled "select" element, use the "listbox" controller instead.
*/
export default class extends Controller {
static targets = [
"button",
"content",
"submenu",
"submenuButton",
"submenuContent",
];
static targets = ["button", "content"];
static values = {
show: { type: Boolean, default: false },
showSubmenu: { type: Boolean, default: false },
show: Boolean,
placement: { type: String, default: "bottom-end" },
offset: { type: Number, default: 6 },
};
initialize() {
connect() {
this.show = this.showValue;
this.showSubmenu = this.showSubmenuValue;
this.boundUpdate = this.update.bind(this);
this.addEventListeners();
this.startAutoUpdate();
}
connect() {
disconnect() {
this.removeEventListeners();
this.stopAutoUpdate();
this.close();
}
addEventListeners() {
this.buttonTarget.addEventListener("click", this.toggle);
this.element.addEventListener("keydown", this.handleKeydown);
document.addEventListener("click", this.handleOutsideClick);
document.addEventListener("turbo:load", this.handleTurboLoad);
}
disconnect() {
this.element.removeEventListener("keydown", this.handleKeydown);
removeEventListeners() {
this.buttonTarget.removeEventListener("click", this.toggle);
this.element.removeEventListener("keydown", this.handleKeydown);
document.removeEventListener("click", this.handleOutsideClick);
document.removeEventListener("turbo:load", this.handleTurboLoad);
this.close();
}
// If turbo reloads, we maintain the state of the menu
handleTurboLoad = () => {
if (!this.show) this.close();
};
handleOutsideClick = (event) => {
if (this.show && !this.element.contains(event.target)) {
this.close();
}
if (this.show && !this.element.contains(event.target)) this.close();
};
handleKeydown = (event) => {
switch (event.key) {
case "Escape":
this.close();
this.buttonTarget.focus(); // Bring focus back to the button
break;
if (event.key === "Escape") {
this.close();
this.buttonTarget.focus();
}
};
@@ -63,6 +59,7 @@ export default class extends Controller {
this.show = !this.show;
this.contentTarget.classList.toggle("hidden", !this.show);
if (this.show) {
this.update();
this.focusFirstElement();
}
};
@@ -73,12 +70,40 @@ export default class extends Controller {
}
focusFirstElement() {
const focusableElements =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const firstFocusableElement =
this.contentTarget.querySelectorAll(focusableElements)[0];
const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const firstFocusableElement = this.contentTarget.querySelectorAll(focusableElements)[0];
if (firstFocusableElement) {
firstFocusableElement.focus();
}
}
startAutoUpdate() {
if (!this._cleanup) {
this._cleanup = autoUpdate(this.buttonTarget, this.contentTarget, this.boundUpdate);
}
}
stopAutoUpdate() {
if (this._cleanup) {
this._cleanup();
this._cleanup = null;
}
}
update() {
computePosition(this.buttonTarget, this.contentTarget, {
placement: this.placementValue,
middleware: [
offset(this.offsetValue),
flip(),
shift({ padding: 5 })
],
}).then(({ x, y }) => {
Object.assign(this.contentTarget.style, {
position: 'fixed',
left: `${x}px`,
top: `${y}px`,
});
});
}
}

View File

@@ -13,7 +13,6 @@ export default class extends Controller {
updateAmount(currency) {
(new CurrenciesService).get(currency).then((currency) => {
console.log(currency)
this.amountTarget.step = currency.step;
if (isFinite(this.amountTarget.value)) {

View File

@@ -11,12 +11,12 @@ export default class extends Controller {
usePercentSign: Boolean
}
#d3SvgMemo = null
#d3GroupMemo = null
#d3Tooltip = null
#d3InitialContainerWidth = 0
#d3InitialContainerHeight = 0
#normalDataPoints = []
#d3SvgMemo = null;
#d3GroupMemo = null;
#d3Tooltip = null;
#d3InitialContainerWidth = 0;
#d3InitialContainerHeight = 0;
#normalDataPoints = [];
connect() {
this.#install()
@@ -181,7 +181,7 @@ export default class extends Controller {
.call(
d3
.axisBottom(this.#d3XScale)
.tickValues([ this.#normalDataPoints[0].date, this.#normalDataPoints[this.#normalDataPoints.length - 1].date ])
.tickValues([this.#normalDataPoints[0].date, this.#normalDataPoints[this.#normalDataPoints.length - 1].date])
.tickSize(0)
.tickFormat(d3.timeFormat("%d %b %Y"))
)
@@ -247,7 +247,6 @@ export default class extends Controller {
.style("fill", `url(#${this.element.id}-trendline-gradient)`)
}
#drawTooltip() {
this.#d3Tooltip = d3
.select(`#${this.element.id}`)
@@ -345,7 +344,7 @@ export default class extends Controller {
}
#tooltipTemplate(datum) {
return(`
return (`
<div style="margin-bottom: 4px; color: ${tailwindColors.gray[500]};">
${d3.timeFormat("%b %d, %Y")(datum.date)}
</div>
@@ -428,7 +427,7 @@ export default class extends Controller {
.append("svg")
.attr("width", this.#d3InitialContainerWidth)
.attr("height", this.#d3InitialContainerHeight)
.attr("viewBox", [ 0, 0, this.#d3InitialContainerWidth, this.#d3InitialContainerHeight ])
.attr("viewBox", [0, 0, this.#d3InitialContainerWidth, this.#d3InitialContainerHeight])
}
#createMainGroup() {
@@ -502,7 +501,7 @@ export default class extends Controller {
get #d3XScale() {
return d3
.scaleTime()
.rangeRound([ 0, this.#d3ContainerWidth ])
.rangeRound([0, this.#d3ContainerWidth])
.domain(d3.extent(this.#normalDataPoints, d => d.date))
}
@@ -514,7 +513,7 @@ export default class extends Controller {
return d3
.scaleLinear()
.rangeRound([ this.#d3ContainerHeight, 0 ])
.domain([ dataMin - padding, dataMax + padding ])
.rangeRound([this.#d3ContainerHeight, 0])
.domain([dataMin - padding, dataMax + padding])
}
}

View File

@@ -1,16 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: ENV["EMAIL_SENDER"] if ENV["EMAIL_SENDER"].present?
layout "mailer"
after_action :set_self_host_settings, if: -> { Rails.configuration.app_mode.self_hosted? }
private
def set_self_host_settings
mail.from = Setting.email_sender
mail.delivery_method.settings.merge!({ address: Setting.smtp_host,
port: Setting.smtp_port,
user_name: Setting.smtp_username,
password: Setting.smtp_password,
tls: ENV.fetch("SMTP_TLS_ENABLED", "true") == "true" })
end
end

View File

@@ -1,5 +0,0 @@
class NotificationMailer < ApplicationMailer
def test_email
mail(to: params[:user].email, subject: t(".test_email_subject"), body: t(".test_email_body"))
end
end

View File

@@ -1,5 +1,9 @@
class PasswordMailer < ApplicationMailer
def password_reset
mail to: params[:user].email
@user = params[:user]
@subject = t(".subject")
@cta = t(".cta")
mail to: @user.email, subject: @subject
end
end

View File

@@ -5,14 +5,15 @@ class Account < ApplicationRecord
belongs_to :family
belongs_to :institution, optional: true
belongs_to :import, optional: true
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
has_many :holdings, dependent: :destroy
has_many :balances, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :syncs, dependent: :destroy
has_many :issues, as: :issuable, dependent: :destroy
@@ -38,13 +39,16 @@ class Account < ApplicationRecord
Accountable.by_classification.each do |classification, types|
types.each do |type|
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
self.where(accountable_type: type).each do |account|
group.add_value_node(
account,
account.balance_money.exchange_to(currency, fallback_rate: 0),
account.series(period: period, currency: currency)
)
accounts = self.where(accountable_type: type)
if accounts.any?
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
accounts.each do |account|
group.add_value_node(
account,
account.balance_money.exchange_to(currency, fallback_rate: 0),
account.series(period: period, currency: currency)
)
end
end
end
end
@@ -78,6 +82,11 @@ class Account < ApplicationRecord
end
end
def original_balance
balance_amount = balances.chronological.first&.balance || balance
Money.new(balance_amount, currency)
end
def owns_ticker?(ticker)
security_id = Security.find_by(ticker: ticker)&.id
entries.account_trades
@@ -89,6 +98,15 @@ class Account < ApplicationRecord
classification == "asset" ? "up" : "down"
end
def update_with_sync!(attributes)
transaction do
update!(attributes)
update_balance!(attributes[:balance]) if attributes[:balance]
end
sync_later
end
def update_balance!(balance)
valuation = entries.account_valuations.find_by(date: Date.current)

View File

@@ -14,7 +14,8 @@ class Account::Balance::Syncer
if daily_balances.any?
account.reload
account.update! balance: daily_balances.select { |db| db.currency == account.currency }.last&.balance
last_balance = daily_balances.select { |db| db.currency == account.currency }.last&.balance
account.update! balance: last_balance
end
end
rescue Money::ConversionError => e
@@ -102,7 +103,7 @@ class Account::Balance::Syncer
end
def find_prior_balance
account.balances.where("date < ?", sync_start_date).order(date: :desc).first&.balance
account.balances.where(currency: account.currency).where("date < ?", sync_start_date).order(date: :desc).first&.balance
end
def net_entry_flows(entries, target_currency = account.currency)

View File

@@ -5,6 +5,7 @@ class Account::Entry < ApplicationRecord
belongs_to :account
belongs_to :transfer, optional: true
belongs_to :import, optional: true
delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy
accepts_nested_attributes_for :entryable
@@ -12,7 +13,6 @@ class Account::Entry < ApplicationRecord
validates :date, :amount, :currency, presence: true
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
validates :date, comparison: { greater_than: -> { min_supported_date } }
validate :trade_valid?, if: -> { account_trade? }
scope :chronological, -> { order(:date, :created_at) }
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
@@ -109,8 +109,8 @@ class Account::Entry < ApplicationRecord
def bulk_update!(bulk_update_params)
bulk_attributes = {
date: bulk_update_params[:date],
notes: bulk_update_params[:notes],
entryable_attributes: {
notes: bulk_update_params[:notes],
category_id: bulk_update_params[:category_id],
merchant_id: bulk_update_params[:merchant_id]
}.compact_blank
@@ -129,17 +129,21 @@ class Account::Entry < ApplicationRecord
end
def income_total(currency = "USD")
without_transfers.account_transactions.includes(:entryable)
total = without_transfers.account_transactions.includes(:entryable)
.where("account_entries.amount <= 0")
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum
Money.new(total, currency)
end
def expense_total(currency = "USD")
without_transfers.account_transactions.includes(:entryable)
total = without_transfers.account_transactions.includes(:entryable)
.where("account_entries.amount > 0")
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum
Money.new(total, currency)
end
def search(params)
@@ -148,6 +152,27 @@ class Account::Entry < ApplicationRecord
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
@@ -198,21 +223,4 @@ class Account::Entry < ApplicationRecord
previous: previous_entry&.amount_money,
favorable_direction: account.favorable_direction
end
def trade_valid?
if account_trade.sell?
current_qty = account.holding_qty(account_trade.security)
if current_qty < account_trade.qty.abs
# i18n-tasks-use t('activerecord.errors.models.account/entry.attributes.base.invalid_sell_quantity')
errors.add(
:base,
:invalid_sell_quantity,
sell_qty: account_trade.qty.abs,
ticker: account_trade.security.ticker,
current_qty: current_qty
)
end
end
end
end

View File

@@ -37,6 +37,19 @@ class Account::Holding < ApplicationRecord
@trend ||= calculate_trend
end
def trades
account.entries.where(entryable: account.trades.where(security: security)).reverse_chronological
end
def destroy_holding_and_entries!
transaction do
account.entries.where(entryable: account.trades.where(security: security)).destroy_all
destroy
end
account.sync_later
end
private
def calculate_trend

View File

@@ -48,7 +48,9 @@ class Account::Holding::Syncer
end
ticker_start_dates.each do |ticker, date|
prices[ticker] = Security::Price.find_prices(ticker: ticker, start_date: date, end_date: Date.current)
fetched_prices = Security::Price.find_prices(ticker: ticker, start_date: date, end_date: Date.current)
gapfilled_prices = Gapfiller.new(fetched_prices, start_date: date, end_date: Date.current, cache: false).run
prices[ticker] = gapfilled_prices
end
prices

View File

@@ -25,4 +25,15 @@ class Account::Trade < ApplicationRecord
def buy?
qty > 0
end
def unrealized_gain_loss
return nil if sell?
current_price = security.current_price
return nil if current_price.nil?
current_value = current_price * qty.abs
cost_basis = price_money * qty.abs
TimeSeries::Trend.new(current: current_value, previous: cost_basis)
end
end

View File

@@ -13,8 +13,15 @@ class Account::Transaction < ApplicationRecord
class << self
def search(params)
query = all
query = query.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id").where(categories: { name: params[:categories] }) if params[:categories].present?
query = query.joins("LEFT JOIN merchants ON merchants.id = account_transactions.merchant_id").where(merchants: { name: params[:merchants] }) if params[:merchants].present?
query = query.joins(:category).where(categories: { name: params[:categories] }) if params[:categories].present?
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
end
@@ -25,7 +32,7 @@ class Account::Transaction < ApplicationRecord
private
def searchable_keys
%i[categories merchants]
%i[categories merchants tags]
end
end

View File

@@ -21,7 +21,7 @@ class Account::TransactionBuilder
end
def create_transfer
return create_unlinked_transfer(account.id, signed_amount) unless transfer_account_id
return create_unlinked_transfer(account.id, signed_amount) if transfer_account_id.blank?
from_account_id = type == "transfer_in" ? transfer_account_id : account.id
to_account_id = type == "transfer_in" ? account.id : transfer_account_id

View File

@@ -46,7 +46,7 @@ class Account::Transfer < ApplicationRecord
def build_from_accounts(from_account, to_account, date:, amount:, currency:, name:)
outflow = from_account.entries.build \
amount: amount.abs,
currency: currency,
currency: from_account.currency,
date: date,
name: name,
marked_as_transfer: true,
@@ -54,7 +54,7 @@ class Account::Transfer < ApplicationRecord
inflow = to_account.entries.build \
amount: amount.abs * -1,
currency: currency,
currency: from_account.currency,
date: date,
name: name,
marked_as_transfer: true,
@@ -72,27 +72,23 @@ class Account::Transfer < ApplicationRecord
def transaction_count
unless entries.size == 2
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_have_exactly_2_entries')
errors.add :entries, :must_have_exactly_2_entries
end
end
def from_different_accounts
accounts = entries.map { |e| e.account_id }.uniq
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_be_from_different_accounts')
errors.add :entries, :must_be_from_different_accounts if accounts.size < entries.size
end
def net_zero_flows
unless entries.sum(&:amount).zero?
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_have_an_inflow_and_outflow_that_net_to_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)
# i18n-tasks-use t('activerecord.errors.models.account/transfer.attributes.entries.must_be_marked_as_transfer')
errors.add :entries, :must_be_marked_as_transfer
end
end

View File

@@ -0,0 +1,49 @@
class AccountImport < Import
def import!
transaction do
rows.each do |row|
mapping = mappings.account_types.find_by(key: row.entity_type)
accountable_class = mapping.value.constantize
account = family.accounts.build(
name: row.name,
balance: row.amount.to_d,
currency: row.currency,
accountable: accountable_class.new,
import: self
)
account.save!
end
end
end
def mapping_steps
[ Import::AccountTypeMapping ]
end
def required_column_keys
%i[name amount]
end
def column_keys
%i[entity_type name amount currency]
end
def dry_run
{
accounts: rows.count
}
end
def csv_template
template = <<-CSV
Account type*,Name*,Balance*,Currency
Checking,Main Checking Account,1000.00,USD
Savings,Emergency Fund,5000.00,USD
Credit Card,Rewards Card,-500.00,USD
CSV
CSV.parse(template, headers: true)
end
end

View File

@@ -1,9 +1,6 @@
class Address < ApplicationRecord
belongs_to :addressable, polymorphic: true
validates :line1, :locality, presence: true
validates :postal_code, presence: true, if: :postal_code_required?
def to_s
I18n.t("address.format",
line1: line1,
@@ -15,10 +12,4 @@ class Address < ApplicationRecord
postal_code: postal_code
)
end
private
def postal_code_required?
country.in?(%w[US CA GB])
end
end

View File

@@ -1,5 +1,6 @@
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
validates :name, :color, :family, presence: true

View File

@@ -28,7 +28,7 @@ module Accountable
if balance_series.empty? && period.date_range.end == Date.current
TimeSeries.new([ { date: Date.current, value: account.balance_money.exchange_to(currency) } ])
else
TimeSeries.from_collection(balance_series, :balance_money)
TimeSeries.from_collection(balance_series, :balance_money, favorable_direction: account.asset? ? "up" : "down")
end
rescue Money::ConversionError
TimeSeries.new([])

View File

@@ -18,13 +18,12 @@ module Providable
Provider::Github.new
end
def synth_provider
api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"]
api_key.present? ? Provider::Synth.new(api_key) : nil
end
private
def synth_provider
api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"]
api_key.present? ? Provider::Synth.new(api_key) : nil
end
def self_hosted?
Rails.application.config.app_mode.self_hosted?
end

View File

@@ -1,3 +1,15 @@
class CreditCard < ApplicationRecord
include Accountable
def available_credit_money
available_credit ? Money.new(available_credit, account.currency) : nil
end
def minimum_payment_money
minimum_payment ? Money.new(minimum_payment, account.currency) : nil
end
def annual_fee_money
annual_fee ? Money.new(annual_fee, account.currency) : nil
end
end

View File

@@ -1,5 +1,7 @@
class Current < ActiveSupport::CurrentAttributes
attribute :user
attribute :session
attribute :user_agent, :ip_address
delegate :user, to: :session, allow_nil: true
delegate :family, to: :user, allow_nil: true
end

View File

@@ -6,6 +6,7 @@ class Demo::Generator
end
def reset_and_clear_data!
reset_settings!
clear_data!
create_user!
@@ -14,6 +15,7 @@ class Demo::Generator
def reset_data!
Family.transaction do
reset_settings!
clear_data!
create_user!
@@ -52,17 +54,23 @@ class Demo::Generator
end
def clear_data!
InviteCode.destroy_all
User.find_by_email("user@maybe.local")&.destroy
ExchangeRate.destroy_all
Security.destroy_all
Security::Price.destroy_all
end
def reset_settings!
Setting.destroy_all
end
def create_user!
family.users.create! \
email: "user@maybe.local",
first_name: "Demo",
last_name: "User",
role: "admin",
password: "password"
end

View File

@@ -1,15 +1,19 @@
class Family < ApplicationRecord
include Providable
has_many :users, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :institutions, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :transactions, through: :accounts
has_many :entries, through: :accounts
has_many :imports, through: :accounts
has_many :categories, dependent: :destroy
has_many :merchants, dependent: :destroy
has_many :issues, through: :accounts
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
def snapshot(period = Period.all)
query = accounts.active.joins(:balances)
.where("account_balances.currency = ?", self.currency)
@@ -119,4 +123,16 @@ class Family < ApplicationRecord
def needs_sync?
last_synced_at.nil? || last_synced_at.to_date < Date.current
end
def synth_usage
self.class.synth_provider&.usage
end
def subscribed?
stripe_subscription_status.present? && stripe_subscription_status == "active"
end
def primary_user
users.order(:created_at).first
end
end

48
app/models/gapfiller.rb Normal file
View File

@@ -0,0 +1,48 @@
class Gapfiller
attr_reader :series
def initialize(series, start_date:, end_date:, cache:)
@series = series
@date_range = start_date..end_date
@cache = cache
end
def run
gapfilled_records = []
date_range.each do |date|
record = series.find { |r| r.date == date }
if should_gapfill?(date, record)
prev_record = gapfilled_records.find { |r| r.date == date - 1.day }
if prev_record
new_record = create_gapfilled_record(prev_record, date)
gapfilled_records << new_record
end
else
gapfilled_records << record if record
end
end
gapfilled_records
end
private
attr_reader :date_range, :cache
def should_gapfill?(date, record)
(date.on_weekend? || holiday?(date)) && record.nil?
end
def holiday?(date)
Holidays.on(date, :federalreserve, :us, :observed, :informal).any?
end
def create_gapfilled_record(prev_record, date)
new_record = prev_record.class.new(prev_record.attributes.except("id", "created_at", "updated_at"))
new_record.date = date
new_record.save! if cache
new_record
end
end

View File

@@ -1,186 +1,150 @@
class Import < ApplicationRecord
belongs_to :account
TYPES = %w[TransactionImport TradeImport AccountImport MintImport].freeze
SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative]
validate :raw_file_must_be_parsable
validates :col_sep, inclusion: { in: Csv::COL_SEP_LIST }
before_save :initialize_csv, if: :should_initialize_csv?
enum :status, { pending: "pending", complete: "complete", importing: "importing", failed: "failed" }, validate: true
store_accessor :column_mappings, :define_column_mapping_keys
belongs_to :family
scope :ordered, -> { order(created_at: :desc) }
FALLBACK_TRANSACTION_NAME = "Imported transaction"
enum :status, { pending: "pending", complete: "complete", importing: "importing", failed: "failed" }, validate: true
validates :type, inclusion: { in: TYPES }
validates :col_sep, inclusion: { in: [ ",", ";" ] }
validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }
has_many :rows, dependent: :destroy
has_many :mappings, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
def publish_later
raise "Import is not publishable" unless publishable?
update! status: :importing
ImportJob.perform_later(self)
end
def loaded?
def publish
import!
family.sync
update! status: :complete
rescue => error
update! status: :failed, error: error.message
end
def csv_rows
@csv_rows ||= parsed_csv
end
def csv_headers
parsed_csv.headers
end
def csv_sample
@csv_sample ||= parsed_csv.first(2)
end
def dry_run
{
transactions: rows.count,
accounts: Import::AccountMapping.for_import(self).creational.count,
categories: Import::CategoryMapping.for_import(self).creational.count,
tags: Import::TagMapping.for_import(self).creational.count
}
end
def required_column_keys
[]
end
def column_keys
raise NotImplementedError, "Subclass must implement column_keys"
end
def generate_rows_from_csv
rows.destroy_all
mapped_rows = csv_rows.map do |row|
{
account: row[account_col_label].to_s,
date: row[date_col_label].to_s,
qty: sanitize_number(row[qty_col_label]).to_s,
ticker: row[ticker_col_label].to_s,
price: sanitize_number(row[price_col_label]).to_s,
amount: sanitize_number(row[amount_col_label]).to_s,
currency: (row[currency_col_label] || default_currency).to_s,
name: (row[name_col_label] || default_row_name).to_s,
category: row[category_col_label].to_s,
tags: row[tags_col_label].to_s,
entity_type: row[entity_type_col_label].to_s,
notes: row[notes_col_label].to_s
}
end
rows.insert_all!(mapped_rows)
end
def sync_mappings
mapping_steps.each do |mapping|
mapping.sync(self)
end
end
def mapping_steps
[]
end
def uploaded?
raw_file_str.present?
end
def configured?
csv.present?
uploaded? && rows.any?
end
def cleaned?
loaded? && configured? && csv.valid?
configured? && rows.all?(&:valid?)
end
def csv
get_normalized_csv_with_validation
def publishable?
cleaned? && mappings.all?(&:valid?)
end
def available_headers
get_raw_csv.table.headers
def has_unassigned_account?
mappings.accounts.where(key: "").any?
end
def get_selected_header_for_field(field)
column_mappings&.dig(field.key) || field.key
end
def update_csv!(row_idx:, col_idx:, value:)
updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
update! normalized_csv_str: updated_csv.to_s
end
# Type-specific methods (potential STI inheritance in future when more import types added)
def publish
update!(status: "importing")
transaction do
generate_transactions.each do |txn|
txn.save!
end
end
self.account.sync
update!(status: "complete")
rescue => e
update!(status: "failed")
Rails.logger.error("Import with id #{id} failed: #{e}")
end
def dry_run
generate_transactions
end
def expected_fields
@expected_fields ||= create_expected_fields
def requires_account?
family.accounts.empty? && has_unassigned_account?
end
private
def get_normalized_csv_with_validation
return nil if normalized_csv_str.nil?
csv = Import::Csv.new(normalized_csv_str)
expected_fields.each do |field|
csv.define_validator(field.key, field.validator) if field.validator
end
csv
def import!
# no-op, subclasses can implement for customization of algorithm
end
def get_raw_csv
return nil if raw_file_str.nil?
Import::Csv.new(raw_file_str, col_sep:)
def default_row_name
"Imported item"
end
def should_initialize_csv?
raw_file_str_changed? || column_mappings_changed?
def default_currency
family.currency
end
def initialize_csv
generated_csv = generate_normalized_csv(raw_file_str)
self.normalized_csv_str = generated_csv.table.to_s
def parsed_csv
@parsed_csv ||= CSV.parse(
(raw_file_str || "").strip,
headers: true,
col_sep: col_sep,
converters: [ ->(str) { str&.strip } ]
)
end
# Uses the user-provided raw CSV + mappings to generate a normalized CSV for the import
def generate_normalized_csv(csv_str)
Import::Csv.create_with_field_mappings(csv_str, expected_fields, column_mappings, col_sep)
end
def update_csv(row_idx, col_idx, value)
updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
update! normalized_csv_str: updated_csv.to_s
end
def generate_transactions
transaction_entries = []
category_cache = {}
tag_cache = {}
csv.table.each do |row|
category_name = row["category"].presence
tag_strings = row["tags"].presence&.split("|") || []
tags = []
tag_strings.each do |tag_string|
tags << tag_cache[tag_string] ||= account.family.tags.find_or_initialize_by(name: tag_string)
end
category = category_cache[category_name] ||= account.family.categories.find_or_initialize_by(name: category_name) if category_name.present?
entry = account.entries.build \
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
date: Date.iso8601(row["date"]),
currency: account.currency,
amount: BigDecimal(row["amount"]) * -1,
entryable: Account::Transaction.new(category: category, tags: tags)
transaction_entries << entry
end
transaction_entries
end
def create_expected_fields
date_field = Import::Field.new \
key: "date",
label: "Date",
validator: ->(value) { Import::Field.iso_date_validator(value) }
name_field = Import::Field.new \
key: "name",
label: "Name",
is_optional: true
category_field = Import::Field.new \
key: "category",
label: "Category",
is_optional: true
tags_field = Import::Field.new \
key: "tags",
label: "Tags",
is_optional: true
amount_field = Import::Field.new \
key: "amount",
label: "Amount",
validator: ->(value) { Import::Field.bigdecimal_validator(value) }
[ date_field, name_field, category_field, tags_field, amount_field ]
end
def define_column_mapping_keys
expected_fields.each do |field|
field.key.to_sym
end
end
def raw_file_must_be_parsable
begin
CSV.parse(raw_file_str || "", col_sep:)
rescue CSV::MalformedCSVError
# i18n-tasks-use t('activerecord.errors.models.import.attributes.raw_file_str.invalid_csv_format')
errors.add(:raw_file_str, :invalid_csv_format)
end
def sanitize_number(value)
return "" if value.nil?
value.gsub(/[^\d.\-]/, "")
end
end

View File

@@ -0,0 +1,45 @@
class Import::AccountMapping < Import::Mapping
validates :mappable, presence: true, if: -> { key.blank? || !create_when_empty }
class << self
def mapping_values(import)
import.rows.map(&:account).uniq
end
end
def selectable_values
family_accounts = import.family.accounts.alphabetically.map { |account| [ account.name, account.id ] }
unless key.blank?
family_accounts.unshift [ "Add as new account", CREATE_NEW_KEY ]
end
family_accounts
end
def requires_selection?
true
end
def values_count
import.rows.where(account: key).count
end
def mappable_class
Account
end
def create_mappable!
return unless creatable?
account = import.family.accounts.create_or_find_by!(name: key) do |new_account|
new_account.balance = 0
new_account.import = import
new_account.currency = import.family.currency
new_account.accountable = Depository.new
end
self.mappable = account
save!
end
end

View File

@@ -0,0 +1,25 @@
class Import::AccountTypeMapping < Import::Mapping
validates :value, presence: true
class << self
def mapping_values(import)
import.rows.map(&:entity_type).uniq
end
end
def selectable_values
Accountable::TYPES.map { |type| [ type.titleize, type ] }
end
def requires_selection?
true
end
def values_count
import.rows.where(entity_type: key).count
end
def create_mappable!
# no-op
end
end

View File

@@ -0,0 +1,36 @@
class Import::CategoryMapping < Import::Mapping
class << self
def mapping_values(import)
import.rows.map(&:category).uniq
end
end
def selectable_values
family_categories = import.family.categories.alphabetically.map { |category| [ category.name, category.id ] }
unless key.blank?
family_categories.unshift [ "Add as new category", CREATE_NEW_KEY ]
end
family_categories
end
def requires_selection?
false
end
def values_count
import.rows.where(category: key).count
end
def mappable_class
Category
end
def create_mappable!
return unless creatable?
self.mappable = import.family.categories.find_or_create_by!(name: key)
save!
end
end

View File

@@ -1,83 +0,0 @@
class Import::Csv
DEFAULT_COL_SEP = ",".freeze
COL_SEP_LIST = [ DEFAULT_COL_SEP, ";" ].freeze
def self.parse_csv(csv_str, col_sep: DEFAULT_COL_SEP)
CSV.parse(
csv_str&.strip || "",
headers: true,
col_sep:,
converters: [ ->(str) { str&.strip } ]
)
end
def self.create_with_field_mappings(raw_file_str, fields, field_mappings, col_sep = DEFAULT_COL_SEP)
raw_csv = self.parse_csv(raw_file_str, col_sep:)
generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true, col_sep: do |csv|
raw_csv.each do |row|
row_values = []
fields.each do |field|
# Finds the column header name the user has designated for the expected field
mapped_field_key = field_mappings[field.key] if field_mappings
mapped_header = mapped_field_key || field.key
row_values << row.fetch(mapped_header, "")
end
csv << row_values
end
end
new(generated_csv_str, col_sep:)
end
attr_reader :csv_str, :col_sep
def initialize(csv_str, column_validators: nil, col_sep: DEFAULT_COL_SEP)
@csv_str = csv_str
@col_sep = col_sep
@column_validators = column_validators || {}
end
def table
@table ||= self.class.parse_csv(csv_str, col_sep:)
end
def update_cell(row_idx, col_idx, value)
copy = table.by_col_or_row
copy[row_idx][col_idx] = value
copy
end
def valid?
table.each_with_index.all? do |row, row_idx|
row.each_with_index.all? do |cell, col_idx|
cell_valid?(row_idx, col_idx)
end
end
end
def cell_valid?(row_idx, col_idx)
value = table.dig(row_idx, col_idx)
header = table.headers[col_idx]
validator = get_validator_by_header(header)
validator.call(value)
end
def define_validator(header_key, validator = nil, &block)
header = table.headers.find { |h| h.strip == header_key }
raise "Cannot define validator for header #{header_key}: header does not exist in CSV" if header.nil?
column_validators[header] = validator || block
end
private
attr_accessor :column_validators
def get_validator_by_header(header)
column_validators&.dig(header) || ->(_v) { true }
end
end

View File

@@ -1,37 +0,0 @@
class Import::Field
def self.iso_date_validator(value)
Date.iso8601(value)
true
rescue
false
end
def self.bigdecimal_validator(value)
BigDecimal(value)
true
rescue
false
end
attr_reader :key, :label, :validator
def initialize(key:, label:, is_optional: false, validator: nil)
@key = key.to_s
@label = label
@is_optional = is_optional
@validator = validator
end
def optional?
@is_optional
end
def define_validator(validator = nil, &block)
@validator = validator || block
end
def validate(value)
return true if validator.nil?
validator.call(value)
end
end

View File

@@ -0,0 +1,56 @@
class Import::Mapping < ApplicationRecord
CREATE_NEW_KEY = "internal_new_resource"
belongs_to :import
belongs_to :mappable, polymorphic: true, optional: true
validates :key, presence: true, uniqueness: { scope: [ :import_id, :type ] }, allow_blank: true
scope :for_import, ->(import) { where(import: import) }
scope :creational, -> { where(create_when_empty: true, mappable: nil) }
scope :categories, -> { where(type: "Import::CategoryMapping") }
scope :tags, -> { where(type: "Import::TagMapping") }
scope :accounts, -> { where(type: "Import::AccountMapping") }
scope :account_types, -> { where(type: "Import::AccountTypeMapping") }
class << self
def mappable_for(key)
find_by(key: key)&.mappable
end
def sync(import)
unique_values = mapping_values(import).uniq
unique_values.each do |value|
mapping = find_or_initialize_by(key: value, import: import, create_when_empty: value.present?)
mapping.save(validate: false) if mapping.new_record?
end
where(import: import).where.not(key: unique_values).destroy_all
end
def mapping_values(import)
raise NotImplementedError, "Subclass must implement mapping_values"
end
end
def selectable_values
raise NotImplementedError, "Subclass must implement selectable_values"
end
def values_count
raise NotImplementedError, "Subclass must implement values_count"
end
def mappable_class
nil
end
def creatable?
mappable.nil? && key.present? && create_when_empty
end
def create_mappable!
raise NotImplementedError, "Subclass must implement create_mappable!"
end
end

84
app/models/import/row.rb Normal file
View File

@@ -0,0 +1,84 @@
class Import::Row < ApplicationRecord
belongs_to :import
validates :amount, numericality: true, allow_blank: true
validates :currency, presence: true
validate :date_valid
validate :required_columns
validate :currency_is_valid
scope :ordered, -> { order(:id) }
def tags_list
if tags.blank?
[ "" ]
else
tags.split("|").map(&:strip)
end
end
def date_iso
Date.strptime(date, import.date_format).iso8601
end
def signed_amount
if import.type == "TradeImport"
price.to_d * apply_trade_signage_convention(qty.to_d)
else
apply_transaction_signage_convention(amount.to_d)
end
end
def sync_mappings
Import::CategoryMapping.sync(import) if import.column_keys.include?(:category)
Import::TagMapping.sync(import) if import.column_keys.include?(:tags)
Import::AccountMapping.sync(import) if import.column_keys.include?(:account)
Import::AccountTypeMapping.sync(import) if import.column_keys.include?(:entity_type)
end
private
# In the Maybe system, positive quantities == "inflows"
def apply_trade_signage_convention(value)
value * (import.signage_convention == "inflows_positive" ? 1 : -1)
end
# In the Maybe system, positive amounts == "outflows", so we must reverse signage
def apply_transaction_signage_convention(value)
value * (import.signage_convention == "inflows_positive" ? -1 : 1)
end
def required_columns
import.required_column_keys.each do |required_key|
errors.add(required_key, "is required") if self[required_key].blank?
end
end
def date_valid
return if date.blank?
parsed_date = Date.strptime(date, import.date_format) rescue nil
if parsed_date.nil?
errors.add(:date, "must exactly match the format: #{import.date_format}")
return
end
min_date = Account::Entry.min_supported_date
max_date = Date.current
if parsed_date < min_date || parsed_date > max_date
errors.add(:date, "must be between #{min_date} and #{max_date}")
end
end
def currency_is_valid
return true if currency.blank?
begin
Money::Currency.new(currency)
rescue Money::Currency::UnknownCurrencyError
errors.add(:currency, "is not a valid currency code")
end
end
end

View File

@@ -0,0 +1,36 @@
class Import::TagMapping < Import::Mapping
class << self
def mapping_values(import)
import.rows.map(&:tags_list).flatten.uniq
end
end
def selectable_values
family_tags = import.family.tags.alphabetically.map { |tag| [ tag.name, tag.id ] }
unless key.blank?
family_tags.unshift [ "Add as new tag", CREATE_NEW_KEY ]
end
family_tags
end
def requires_selection?
false
end
def values_count
import.rows.map(&:tags_list).flatten.count { |tag| tag == key }
end
def mappable_class
Tag
end
def create_mappable!
return unless creatable?
self.mappable = import.family.tags.find_or_create_by!(name: key)
save!
end
end

View File

@@ -4,4 +4,22 @@ class Institution < ApplicationRecord
has_one_attached :logo
scope :alphabetically, -> { order(name: :asc) }
def sync
accounts.active.each do |account|
if account.needs_sync?
account.sync
end
end
update! last_synced_at: Time.now
end
def syncing?
accounts.active.any? { |account| account.syncing? }
end
def has_issues?
accounts.active.any? { |account| account.has_issues? }
end
end

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