Compare commits

...

105 Commits

Author SHA1 Message Date
Zach Gollwitzer
9fa3698823 Bump to v0.5.0
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2025-04-11 11:57:02 -04:00
Josh Pigford
88a6373e84 Implement dark mode (#2078)
* User theme settings

* Initial rough pass on colors

* More progress on dark mode
2025-04-11 09:28:00 -05:00
Zach Gollwitzer
52d170e36c Mobile responsive template preparation (#2071)
* Mobile responsive template

* Fix sidebar mobile conflict

* Lint fix
2025-04-09 12:42:46 -04:00
Akshay Birajdar
2bc3887262 Fix currency symbol for Uncategorized budget to match budget currency (#2058)
Previously, the symbol for the 'Uncategorized' segment defaulted to `$`, which is incorrect for non-USD budgets. This change ensures the correct currency symbol is shown based on the budget's currency.

Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2025-04-08 12:02:05 -04:00
dependabot[bot]
0da057b792 Bump sidekiq from 8.0.1 to 8.0.2 (#2059)
Bumps [sidekiq](https://github.com/sidekiq/sidekiq) from 8.0.1 to 8.0.2.
- [Changelog](https://github.com/sidekiq/sidekiq/blob/main/Changes.md)
- [Commits](https://github.com/sidekiq/sidekiq/compare/v8.0.1...v8.0.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2025-04-08 11:48:14 -04:00
dependabot[bot]
0e1c902b63 Bump selenium-webdriver from 4.30.1 to 4.31.0 (#2060)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.30.1 to 4.31.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/commits/selenium-4.31.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2025-04-08 11:47:53 -04:00
dependabot[bot]
bb0f0239fb Bump faraday-retry from 2.3.0 to 2.3.1 (#2061)
Bumps [faraday-retry](https://github.com/lostisland/faraday-retry) from 2.3.0 to 2.3.1.
- [Release notes](https://github.com/lostisland/faraday-retry/releases)
- [Changelog](https://github.com/lostisland/faraday-retry/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday-retry/compare/v2.3.0...v2.3.1)

---
updated-dependencies:
- dependency-name: faraday-retry
  dependency-version: 2.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2025-04-08 11:47:46 -04:00
dependabot[bot]
e6c1c5f368 Bump vernier from 1.6.0 to 1.7.0 (#2062)
Bumps [vernier](https://github.com/jhawthorn/vernier) from 1.6.0 to 1.7.0.
- [Release notes](https://github.com/jhawthorn/vernier/releases)
- [Commits](https://github.com/jhawthorn/vernier/compare/v1.6.0...v1.7.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2025-04-08 11:47:36 -04:00
dependabot[bot]
02bbeeaec5 Bump stripe from 13.5.0 to 14.0.0 (#2063)
Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.5.0 to 14.0.0.
- [Release notes](https://github.com/stripe/stripe-ruby/releases)
- [Changelog](https://github.com/stripe/stripe-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-ruby/compare/v13.5.0...v14.0.0)

---
updated-dependencies:
- dependency-name: stripe
  dependency-version: 14.0.0
  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>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2025-04-08 11:25:49 -04:00
dependabot[bot]
1649e991b4 Bump brakeman from 7.0.1 to 7.0.2 (#2064)
Bumps [brakeman](https://github.com/presidentbeef/brakeman) from 7.0.1 to 7.0.2.
- [Release notes](https://github.com/presidentbeef/brakeman/releases)
- [Changelog](https://github.com/presidentbeef/brakeman/blob/main/CHANGES.md)
- [Commits](https://github.com/presidentbeef/brakeman/compare/v7.0.1...v7.0.2)

---
updated-dependencies:
- dependency-name: brakeman
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-08 11:00:11 -04:00
Akshay Birajdar
7096eefa2b Fix transfers#update to save notes (#2053) 2025-04-04 12:15:47 -04:00
Zach Gollwitzer
4c72231312 Update brakeman 2025-04-04 12:15:10 -04:00
Joseph Ho
d86ccd36b6 provider: Ensure data provider exist before fetching for price. (#2045) 2025-04-04 12:14:28 -04:00
Zach Gollwitzer
02bfa9f251 Fix AI sidebar overflow when user hasn't enabled or created a chat yet (#2044) 2025-04-01 14:36:34 -04:00
Josh Pigford
f2020a816a Apparently capitalization matters 2025-04-01 08:21:46 -05:00
dependabot[bot]
5f2a031d4c Bump ruby-openai from 8.0.0 to 8.1.0 (#2036)
Bumps [ruby-openai](https://github.com/alexrudall/ruby-openai) from 8.0.0 to 8.1.0.
- [Release notes](https://github.com/alexrudall/ruby-openai/releases)
- [Changelog](https://github.com/alexrudall/ruby-openai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/alexrudall/ruby-openai/compare/v8.0.0...v8.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 09:11:56 -04:00
Zach Gollwitzer
939244bd3e Use faraday retry, move retry logic to concrete provider level (#2042) 2025-04-01 08:41:49 -04:00
Joseph Ho
0a17b84566 perf(imports): Bulk import CSV trades (#2040) 2025-04-01 07:58:49 -04:00
Zach Gollwitzer
5cf758bd03 improvements(ai): Improve AI streaming UI/UX interactions + better separation of AI provider responsibilities (#2039)
* Start refactor

* Interface updates

* Rework Assistant, Provider, and tests for better domain boundaries

* Consolidate and simplify OpenAI provider and provider concepts

* Clean up assistant streaming

* Improve assistant message orchestration logic

* Clean up "thinking" UI interactions

* Remove stale class

* Regenerate VCR test responses
2025-04-01 07:21:54 -04:00
Josh Pigford
6331788b33 Update Intercom configuration to use symbol keys for custom data attributes 2025-03-31 09:39:17 -05:00
Josh Pigford
83bee295ca Add custom data to Intercom configuration 2025-03-31 09:02:25 -05:00
Zach Gollwitzer
dc17a0a298 Make provider errors more specific 2025-03-28 17:53:04 -04:00
Zach Gollwitzer
29f445d75e Fix security search 2025-03-28 17:34:29 -04:00
Zach Gollwitzer
9fadfe074b Disable turbo on onboarding form 2025-03-28 17:24:17 -04:00
Josh Pigford
2a505b000c Fix for unnecessary CSS file 2025-03-28 15:35:26 -05:00
Josh Pigford
36a66baf00 Slight adjustments to AI prompt 2025-03-28 15:30:11 -05:00
Zach Gollwitzer
67716f3006 Add default queue as fallback 2025-03-28 13:29:56 -04:00
Zach Gollwitzer
1061aacb0f Set AI queue 2025-03-28 13:27:50 -04:00
Zach Gollwitzer
2f6b11c18f Personal finance AI (v1) (#2022)
* AI sidebar

* Add chat and message models with associations

* Implement AI chat functionality with sidebar and messaging system

- Add chat and messages controllers
- Create chat and message views
- Implement chat-related routes
- Add message broadcasting and user interactions
- Update application layout to support chat sidebar
- Enhance user model with initials method

* Refactor AI sidebar with enhanced chat menu and interactions

- Update sidebar layout with dynamic width and improved responsiveness
- Add new chat menu Stimulus controller for toggling between chat and chat list views
- Improve chat list display with recent chats and empty state
- Extract AI avatar to a partial for reusability
- Enhance message display and interaction styling
- Add more contextual buttons and interaction hints

* Improve chat scroll behavior and message styling

- Refactor chat scroll functionality with Stimulus controller
- Optimize message scrolling in chat views
- Update message styling for better visual hierarchy
- Enhance chat container layout with flex and auto-scroll
- Simplify message rendering across different chat views

* Extract AI avatar to a shared partial for consistent styling

- Refactor AI avatar rendering across chat views
- Replace hardcoded avatar markup with a reusable partial
- Simplify avatar display in chats and messages views

* Update sidebar controller to handle right panel width dynamically

- Add conditional width class for right sidebar panel
- Ensure consistent sidebar toggle behavior for both left and right panels
- Use specific width class for right panel (w-[375px])

* Refactor chat form and AI greeting with flexible partials

- Extract message form to a reusable partial with dynamic context support
- Create flexible AI greeting partial for consistent welcome messages
- Simplify chat and sidebar views by leveraging new partials
- Add support for different form scenarios (chat, new chat, sidebar)
- Improve code modularity and reduce duplication

* Add chat clearing functionality with dynamic menu options

- Implement clear chat action in ChatsController
- Add clear chat route to support clearing messages
- Update AI sidebar with dropdown menu for chat actions
- Preserve system message when clearing chat
- Enhance chat interaction with new menu options

* Add frontmatter to project structure documentation

- Create initial frontmatter for structure.mdc file
- Include description and configuration options
- Prepare for potential dynamic documentation rendering

* Update general project rules with additional guidelines

- Add rule for using `Current.family` instead of `current_family`
- Include new guidelines for testing, API routes, and solution approach
- Expand project-specific rules for more consistent development practices

* Add OpenAI gem and AI-friendly data representations

- Add `ruby-openai` gem for AI integration
- Implement `to_ai_readable_hash` methods in BalanceSheet and IncomeStatement
- Include Promptable module in both models
- Add savings rate calculation method in IncomeStatement
- Prepare financial models for AI-powered insights and interactions

* Enhance AI Financial Assistant with Advanced Querying and Debugging Capabilities

- Implement comprehensive AI financial query system with function-based interactions
- Add detailed debug logging for AI responses and function calls
- Extend BalanceSheet and IncomeStatement models with AI-friendly methods
- Create robust error handling and fallback mechanisms for AI queries
- Update chat and message views to support debug mode and enhanced rendering
- Add AI query routes and initial test coverage for financial assistant

* Refactor AI sidebar and chat layout with improved structure and comments

- Remove inline AI chat from application layout
- Enhance AI sidebar with more semantic HTML structure
- Add descriptive comments to clarify different sections of chat view
- Improve flex layout and scrolling behavior in chat messages container
- Optimize message rendering with more explicit class names and structure

* Add Markdown rendering support for AI chat messages

- Implement `markdown` helper method in ApplicationHelper using Redcarpet
- Update message view to render AI messages with Markdown formatting
- Add comprehensive Markdown rendering options (tables, code blocks, links)
- Enhance AI Financial Assistant prompt to encourage Markdown usage
- Remove commented Markdown CSS in Tailwind application stylesheet

* Missing comma

* Enhance AI response processing with chat history context

* Improve AI debug logging with payload size limits and internal message flag

* Enhance AI chat interaction with improved thinking indicator and scrolling behavior

* Add AI consent and enable/disable functionality for AI chat

* Upgrade Biome and refactor JavaScript template literals

- Update @biomejs/biome to latest version with caret (^) notation
- Refactor AI query and chat controllers to use template literals
- Standardize npm scripts formatting in package.json

* Add beta testing usage note to AI consent modal

* Update test fixtures and configurations for AI chat functionality

- Add family association to chat fixtures and tests
- Set consistent password digest for test users
- Enable AI for test users
- Add OpenAI access token for test environment
- Update chat and user model tests to include family context

* Simplify data model and get tests passing

* Remove structure.mdc from version control

* Integrate AI chat styles into existing prose pattern

* Match Figma design spec, implement Turbo frames and actions for chats controller

* AI rules refresh

* Consolidate Stimulus controllers, thinking state, controllers, and views

* Naming, domain alignment

* Reset migrations

* Improve data model to support tool calls and message types

* Tool calling tests and fixtures

* Tool call implementation and test

* Get assistant test working again

* Test updates

* Process tool calls within provider

* Chat UI back to working state again

* Remove stale code

* Tests passing

* Update openai class naming to avoid conflicts

* Reconfigure test env

* Rebuild gemfile

* Fix naming conflicts for ChatResponse

* Message styles

* Use OpenAI conversation state management

* Assistant function base implementation

* Add back thinking messages, clean up error handling for chat

* Fix sync error when security price has bad data from provider

* Add balance sheet function to assistant

* Add better function calling error visibility

* Add income statement function

* Simplify and clean up "thinking" interactions with Turbo frames

* Remove stale data definitions from functions

* Ensure VCR fixtures working with latest code

* basic stream implementation

* Get streaming working

* Make AI sidebar wider when left sidebar is collapsed

* Get tests working with streaming responses

* Centralize provider error handling

* Provider data boundaries

---------

Co-authored-by: Josh Pigford <josh@joshpigford.com>
2025-03-28 13:08:22 -04:00
Joseph Ho
8e6b81af77 bug: Use correct currency value while setting the currency. (#2018)
Fixes: #1754.
2025-03-24 10:06:29 -04:00
dependabot[bot]
9f062de6b4 Bump selenium-webdriver from 4.29.1 to 4.30.1 (#2020)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.29.1 to 4.30.1.
- [Release notes](https://github.com/SeleniumHQ/selenium/releases)
- [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES)
- [Commits](https://github.com/SeleniumHQ/selenium/commits)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-24 10:06:18 -04:00
dependabot[bot]
3dfdd0aea5 Bump vernier from 1.5.0 to 1.6.0 (#2019)
Bumps [vernier](https://github.com/jhawthorn/vernier) from 1.5.0 to 1.6.0.
- [Release notes](https://github.com/jhawthorn/vernier/releases)
- [Commits](https://github.com/jhawthorn/vernier/compare/v1.5.0...v1.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-24 10:06:05 -04:00
dependabot[bot]
86431e79a3 Bump csv from 3.3.2 to 3.3.3 (#2021)
Bumps [csv](https://github.com/ruby/csv) from 3.3.2 to 3.3.3.
- [Release notes](https://github.com/ruby/csv/releases)
- [Changelog](https://github.com/ruby/csv/blob/main/NEWS.md)
- [Commits](https://github.com/ruby/csv/compare/v3.3.2...v3.3.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-24 10:03:22 -04:00
Joseph Ho
54f5a44a60 devContainer: Use Redis for ActiveJob and ActionCable. (#2017)
* devContainer: Use Redis for ActiveJob and ActionCable

* devContainer: Simplify environment variables for services.

* devContainer: Remove version field as it's no longer required in Compose.
2025-03-24 10:00:42 -04:00
Joseph Ho
b41897b5e5 import: Bulk import transaction data. (#1962)
Fixes: #1846.
2025-03-24 09:59:27 -04:00
Nick Ostrovsky
f8d64561cf Fix Account Groups wrapping in Balace Sheet (#2010) 2025-03-21 13:18:12 -04:00
Tony Vincent
5a8074c7ee fix: Fix incorrect entry sorting in activity view (#2006) 2025-03-21 10:32:05 -04:00
Zach Gollwitzer
9122eafd31 Update issue templates 2025-03-19 14:05:00 -04:00
Zach Gollwitzer
19cc63c8f4 Use Redis for ActiveJob and ActionCable (#2004)
* Use Redis for ActiveJob and ActionCable

* Fix alwaysApply setting

* Update queue names and weights

* Tweak weights

* Update job queues

* Update docker setup guide

* Remove deprecated upgrade columns from users table

* Refactor Redis configuration for Sidekiq and caching in production environment

* Add Sidekiq Sentry monitoring

* queue naming fix

* Clean up schema
2025-03-19 12:36:16 -04:00
Vaibhav Agrawal
a7db914005 Update security price query in demo generator (#2000) 2025-03-19 08:49:30 -04:00
Zach Gollwitzer
06468a05b1 Update default DB pool size 2025-03-17 20:04:38 -04:00
Zach Gollwitzer
087dd720c1 Report ActionCable errors to Sentry 2025-03-17 17:26:19 -04:00
Zach Gollwitzer
78baf2b327 Update deps 2025-03-17 13:04:59 -04:00
dependabot[bot]
56203b04d3 Bump sentry-rails from 5.22.4 to 5.23.0 (#1996)
Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 5.22.4 to 5.23.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/5.22.4...5.23.0)

---
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>
2025-03-17 11:55:07 -04:00
Zach Gollwitzer
f65b93a352 Data provider simplification, tests, and documentation (#1997)
* Ignore env.test from source control

* Simplification of providers interface

* Synth tests

* Update money to use new find rates method

* Remove unused issues code

* Additional issue feature removals

* Update price data fetching and tests

* Update documentation for providers

* Security test fixes

* Fix self host test

* Update synth usage data access

* Remove AI pr schema changes
2025-03-17 11:54:53 -04:00
Zach Gollwitzer
dd75cadebc Fix transaction filters when transfers are present (#1986)
* Proper filtering of transfers in search

* Fix transaction search
2025-03-11 15:38:45 -04:00
Josh Pigford
ed55ef624b Update billing settings view and locales 2025-03-11 13:00:34 -05:00
Zach Gollwitzer
f363fd4a4e Fix incorrect totals calculation when family has loan payments (#1984)
* Fix income totals calculation error when loan payments exist

* Include transaction totals in totals query
2025-03-11 12:37:57 -04:00
Zach Gollwitzer
b8a3ca7732 Fetch exchange rates for accounts that require conversion for net worth rollups (#1983)
* Sync required exchange rates for accounts

* Refactor into concern
2025-03-11 10:10:28 -04:00
Zach Gollwitzer
7b751ac7ca Do not prompt upgrades until user is logged in
Fixes #1982
2025-03-11 09:11:40 -04:00
Josh Pigford
15d59959cf Fix issue of syncing notice covering up user menu 2025-03-10 11:20:58 -05:00
dependabot[bot]
c66401dc0f Bump stripe from 13.4.1 to 13.5.0 (#1970)
Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.4.1 to 13.5.0.
- [Release notes](https://github.com/stripe/stripe-ruby/releases)
- [Changelog](https://github.com/stripe/stripe-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-ruby/compare/v13.4.1...v13.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 09:34:15 -04:00
dependabot[bot]
9dcb9e8ed2 Bump webmock from 3.25.0 to 3.25.1 (#1968)
Bumps [webmock](https://github.com/bblimke/webmock) from 3.25.0 to 3.25.1.
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.25.0...v3.25.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 09:16:01 -04:00
dependabot[bot]
045fa1931c Bump good_job from 4.9.0 to 4.9.3 (#1969)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.9.0 to 4.9.3.
- [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.9.0...v4.9.3)

---
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>
2025-03-10 09:15:51 -04:00
dependabot[bot]
3f8351abfe Bump rubocop-rails-omakase from 1.0.0 to 1.1.0 (#1971)
Bumps [rubocop-rails-omakase](https://github.com/rails/rubocop-rails-omakase) from 1.0.0 to 1.1.0.
- [Release notes](https://github.com/rails/rubocop-rails-omakase/releases)
- [Commits](https://github.com/rails/rubocop-rails-omakase/compare/v1.0.0...v1.1.0)

---
updated-dependencies:
- dependency-name: rubocop-rails-omakase
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 09:13:38 -04:00
dependabot[bot]
dc44da6c00 Bump redcarpet from 3.6.0 to 3.6.1 (#1972)
Bumps [redcarpet](https://github.com/vmg/redcarpet) from 3.6.0 to 3.6.1.
- [Release notes](https://github.com/vmg/redcarpet/releases)
- [Changelog](https://github.com/vmg/redcarpet/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vmg/redcarpet/compare/v3.6.0...v3.6.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 09:13:25 -04:00
dependabot[bot]
2e4180fbf0 Bump turbo-rails from 2.0.11 to 2.0.13 (#1973)
Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.11 to 2.0.13.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.11...v2.0.13)

---
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>
2025-03-10 09:13:15 -04:00
dependabot[bot]
4b19ca50eb Bump i18n-tasks from 1.0.14 to 1.0.15 (#1974)
Bumps [i18n-tasks](https://github.com/glebm/i18n-tasks) from 1.0.14 to 1.0.15.
- [Release notes](https://github.com/glebm/i18n-tasks/releases)
- [Changelog](https://github.com/glebm/i18n-tasks/blob/main/CHANGES.md)
- [Commits](https://github.com/glebm/i18n-tasks/compare/v1.0.14...v1.0.15)

---
updated-dependencies:
- dependency-name: i18n-tasks
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 09:13:05 -04:00
Zach Gollwitzer
a3cd5f4f1d Format money for trade history in holdings drawer (#1961)
* Format money for trade history in holdings drawer

* Fix broken tests

* Lint fix
2025-03-07 19:09:54 -05:00
Zach Gollwitzer
86bf47a32e Ensure holdings are normalized to account currency 2025-03-07 18:02:08 -05:00
Zach Gollwitzer
5f8a3c9f50 Search securities with correct exchange mic 2025-03-07 17:48:26 -05:00
Zach Gollwitzer
eac5d5e663 Populate holdings for "offline" securities properly (#1958)
* Placeholder logic for missing prices

* Generate holdings properly for "offline" securities

* Separate forward and reverse calculators for holdings and balances

* Remove unnecessary currency conversion during sync

* Clearer sync process

* Move price caching logic to dedicated model

* Base holding calculator

* Base calculator for balances

* Finish balance calculators

* Better naming

* Logs cleanup

* Remove stale data type

* Remove stale test

* Fix price lookup logic for holdings sync

* Fix Plaid item sync regression

* Remove temp logging

* Calculate cash and holdings series

* Add holdings, cash, and balance series dropdown for investments
2025-03-07 17:35:55 -05:00
Nikhil Badyal
26762477a3 Preference to set default_period (#1941) 2025-03-07 10:05:54 -05:00
Zach Gollwitzer
372b64ffea Fix Plaid sync error when current balance is null 2025-03-05 16:02:07 -05:00
Zach Gollwitzer
9627a6bf6f Add tagged logging to sync process (#1956)
* Add tagged logging to sync process

* Reduce logging in syncer

* Typo
2025-03-05 15:38:31 -05:00
Josh Pigford
cffafd23f0 Logger cleanup 2025-03-05 13:44:56 -06:00
Josh Pigford
f7fa8fa085 Disable turbo on login forms 2025-03-05 13:32:53 -06:00
Josh Pigford
28bfcda50a Temporary additional logging to continue debugging MFA issues 2025-03-05 13:20:36 -06:00
Josh Pigford
e49bda4a2e Another attempt at fixing MFA issues 2025-03-05 13:10:53 -06:00
Josh Pigford
071ad52c7f Potential fix for MFA login issues 2025-03-05 13:04:45 -06:00
Zach Gollwitzer
381e39bea8 Fix: Purge stale holdings from accounts during sync (#1954)
* Fix: Purge stale holdings from accounts during sync

* Fix typo

* Prevent Plaid holding deletions
2025-03-05 12:21:17 -05:00
Zach Gollwitzer
eaa1b6abe0 Update issue templates 2025-03-05 11:01:07 -05:00
Zach Gollwitzer
e384369cfb Adjust graph intervals to show more data
Fixes #1948
2025-03-05 10:20:02 -05:00
Zach Gollwitzer
8d0509fda0 Conditionally show commit sha
Fixes #1951
2025-03-05 10:15:12 -05:00
Zach Gollwitzer
d66c37939a Update bug_report.md
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2025-03-05 10:07:29 -05:00
Zach Gollwitzer
cf59fe45e7 Fix ticker filling when Synth is connected (#1950) 2025-03-05 09:30:47 -05:00
Zach Gollwitzer
0544089710 Account-level import configuration templates (#1946)
* Account-level import configuration templates

* Default import to family's preferred date format
2025-03-04 13:10:01 -05:00
Zach Gollwitzer
5b2fa3d707 Fix commit resolution for Docker builds 2025-03-04 07:50:21 -05:00
Bryan McKnight
cf0e573533 Fix modal closing on color picker drag #1869 (#1931)
* Replaced data-action click event with data-action mousedown to prevent the modal from hiding on mouse up whenever mouse down starts within the modal

* Changed click events to mousedown within dialog elements to trigger the closing of the element
2025-03-03 16:37:12 -05:00
Zach Gollwitzer
4e96ca8376 Add manual Docker publishing trigger in GH action workflow 2025-03-03 14:34:56 -05:00
Zach Gollwitzer
c5da8ea550 Allow CSV imports to be configured with single or multi-account mode (#1943)
* Allow CSV imports to be configured to a single account or multiple accounts

* Initialize import directly from accounts page

* Fix brakeman warnings

* Fix schema

* Fix Synth check
2025-03-03 12:47:30 -05:00
Zach Gollwitzer
e907b073ed Fix time period key conflicts (#1944) 2025-03-03 12:47:20 -05:00
Tony Vincent
4c4a4026c4 fix: Bug - Transcation Matching Dialog isn't Opening (#1942) 2025-03-03 11:34:03 -05:00
Zach Gollwitzer
c95bb082a9 Bump to v0.4.3
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2025-02-28 15:11:41 -05:00
Zach Gollwitzer
4d0df9b950 Escape quotations in CSV imports properly (#1929)
* Parse quotes in imports

* Update invalid CSV for test
2025-02-28 12:21:07 -05:00
Zach Gollwitzer
7c66f16750 Invert liability graphs to have correct signage (#1928) 2025-02-28 12:17:25 -05:00
Zach Gollwitzer
fa0248056d Show UI warning to user when they need provider data but have not setup Synth yet (#1926)
* Simplify provider concerns

* Update tests

* Add UI warning for missing Synth key if family requires external data
2025-02-28 11:35:10 -05:00
Tony Vincent
624faa10d0 fix: Don't show Billings on settings navbar when self-hosted (#1912)
* Do not show billing settings navbar item when self hosted

* Do not show billing settings navbar item when self hosted

* Add condition to settings helper

* Let Stripe::AuthenticationError bubble up
2025-02-28 09:35:00 -05:00
Zach Gollwitzer
9138bd2b76 Allow offline trade tickers (#1925) 2025-02-28 09:34:14 -05:00
Zach Gollwitzer
882857fcf0 Add transitions to buttons and other common design system elements (#1924) 2025-02-28 09:29:07 -05:00
Zach Gollwitzer
d6793dec05 Fix import configuration form so number format is applied (#1923)
* Fix number format form error when loading import

* Add test to verify import configuration was properly applied
2025-02-28 08:36:57 -05:00
Zach Gollwitzer
e771c8c1df Fix value wrapping on account balance in sidebar (#1922) 2025-02-28 08:35:14 -05:00
Zach Gollwitzer
58cc09f5ae Fix bad link in bug template
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2025-02-28 08:25:20 -05:00
Zach Gollwitzer
98c842d3b8 Add note about self hosted versions prior to opening bugs 2025-02-28 08:23:46 -05:00
Tony Vincent
fae781e1be Make tags scrollable again (#1921) 2025-02-28 07:53:05 -05:00
Tony Vincent
8208722247 Feat: Data "reset" button (#1913)
* feat: Allow admins to delete family data

* feat: Allow self-hosting users to delete cached data

* Remove system tests
2025-02-28 07:49:12 -05:00
Harshit Chaudhary
f7064fd4dd fixed example account balance (#1910) 2025-02-26 15:13:51 -05:00
Zach Gollwitzer
c610b0ba4b Dashboard design fixes (#1898)
* Dashboard design fixes

* Update dashboard greeting

* Remove sidebar toggle from settings breadcrumbs

* Autofocus and outlines for category dropdowns

* Lint fixes
2025-02-25 17:28:40 -05:00
Josh Pigford
a4874815a6 Add breadcrumbs support across application (#1897)
* Add breadcrumbs support across application

Fixes #1896

* Potential fix for tests

* Simplify breadcrumbs implementation

Remove complex breadcrumbs logic from controllers and concern, replacing with a simpler default approach that sets a basic breadcrumb based on the current controller name

* Refactor page header and breadcrumbs rendering

Remove complex breadcrumbs helper method and update layout to use more flexible content_for approach for page headers and breadcrumbs

* Add fallback breadcrumbs rendering to settings layout
2025-02-25 10:14:07 -06:00
Josh Pigford
763e222cdd Add Sentry user context to authentication concern 2025-02-25 08:48:26 -06:00
Josh Pigford
e8390a68d8 Reduce Sentry sampling rates for performance monitoring 2025-02-25 08:44:13 -06:00
Josh Pigford
0e76d753bd Replace StackProf with Vernier for performance profiling 2025-02-25 08:37:51 -06:00
Zach Gollwitzer
f5ff5332d5 Fix parent category sums in budget (#1894) 2025-02-24 12:51:13 -05:00
Zach Gollwitzer
0dea36ec7d Fix bulk edit drawer height 2025-02-24 12:48:03 -05:00
Syed Bariman Jan
95989a6c9b Add new category flow (#1857)
* resolve git issue

* Add new category flow

* Improve contrast checker

* make error message small

* update ui to match figma design

* realign color picker

* changes

* rename color picker controller to new category controller

* cleanup code

* cleanup code

* resize and realign icon avatar

* Fix js lint errors

Signed-off-by: Syed Bariman Jan <syedbarimanjan@gmail.com>

---------

Signed-off-by: Syed Bariman Jan <syedbarimanjan@gmail.com>
2025-02-24 11:08:05 -05:00
456 changed files with 9665 additions and 4949 deletions

View File

@@ -0,0 +1,23 @@
---
description: Miscellaneous rules to get the AI to behave
globs: *
alwaysApply: true
---
# General rules for AI
- Use `Current.user` for the current user. Do NOT use `current_user`.
- Use `Current.family` for the current family. Do NOT use `current_family`.
- Prior to generating any code, carefully read the project conventions and guidelines
- Read [project-design.mdc](mdc:.cursor/rules/project-design.mdc) to understand the codebase
- Read [project-conventions.mdc](mdc:.cursor/rules/project-conventions.mdc) to understand _how_ to write code for the codebase
- Read [ui-ux-design-guidelines.mdc](mdc:.cursor/rules/ui-ux-design-guidelines.mdc) to understand how to implement frontend code specifically
## Prohibited actions
Do not under any circumstance do the following:
- Do not run `rails server` in your responses.
- Do not run `touch tmp/restart.txt`
- Do not run `rails credentials`
- Do not automatically run migrations
- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development.

View File

@@ -1,14 +1,9 @@
---
description: This rule explains the project's tech stack and code conventions
globs: *
description:
globs:
alwaysApply: true
---
This rule serves as high-level documentation for how the Maybe codebase is structured.
## Rules for AI
- Use this file to understand how the codebase works
- Treat this rule/file as your "source of truth" when making code recommendations
- When creating migrations, always use `rails g migration` instead of creating the file yourself
This rule serves as high-level documentation for how you should write code for the Maybe codebase.
## Project Tech Stack
@@ -18,8 +13,9 @@ This rule serves as high-level documentation for how the Maybe codebase is struc
- Hotwire Turbo/Stimulus for SPA-like UI/UX
- TailwindCSS for styles
- Lucide Icons for icons
- OpenAI for AI chat
- Database: PostgreSQL
- Jobs: GoodJob
- Jobs: Sidekiq + Redis
- External
- Payments: Stripe
- User bank data syncing: Plaid
@@ -46,39 +42,79 @@ This codebase adopts a "skinny controller, fat models" convention. Furthermore,
- When concerns are used for code organization, they should be organized around the "traits" of a model; not for simply moving code to another spot in the codebase.
- When possible, models should answer questions about themselves—for example, we might have a method, `account.balance_series` that returns a time-series of the account's most recent balances. We prefer this over something more service-like such as `AccountSeries.new(account).call`.
### Convention 3: Prefer server-side solutions over client-side solutions
### Convention 3: Leverage Hotwire, write semantic HTML, CSS, and JS, prefer server-side solutions
- When possible, leverage Turbo frames over complex, JS-driven client-side solutions
- When writing a client-side solution, use Stimulus controllers and keep it simple!
- Especially when dealing with money and currencies, calculate + format server-side and then pass that to the client to display
- Keep client-side code for where it truly shines. For example, [bulk_select_controller.js](mdc:app/javascript/controllers/bulk_select_controller.js) is a case where server-side solutions would degrade the user experience significantly. When bulk-selecting entries, client-side solutions are the way to go and Stimulus provides the right toolset to achieve this.
### Convention 4: Sacrifice performance, optimize for simplicitly and clarity
This codebase is still young. We are still rapidly iterating on domain designs and features. Because of this, code should be optimized for simplicitly and clarity over performance.
- Focus on good OOP design first, performance second
- Be mindful of large performance bottlenecks, but don't sweat the small stuff
### Convention 5: Prefer semantic, native HTML features
The HTML spec has improved tremendously over the years and offers a ton of functionality out of the box. We prefer semantic, native HTML solutions over JS-based ones. A few examples of this include:
- Using the `dialog` element for modals
- Using `summary` / `details` elements for disclosures (or `popover` attribute)
- Native HTML is always preferred over JS-based components
- Example 1: Use `<dialog>` element for modals instead of creating a custom component
- Example 2: Use `<details><summary>...</summary></details>` for disclosures rather than custom components
- Leverage Turbo frames to break up the page over JS-driven client-side solutions
- Example 1: A good example of turbo frame usage is in [application.html.erb](mdc:app/views/layouts/application.html.erb) where we load [chats_controller.rb](mdc:app/controllers/chats_controller.rb) actions in a turbo frame in the global layout
- Leverage query params in the URL for state over local storage and sessions. If absolutely necessary, utilize the DB for persistent state.
- Use Turbo streams to enhance functionality, but do not solely depend on it
- Format currencies, numbers, dates, and other values server-side, then pass to Stimulus controllers for display only
- Keep client-side code for where it truly shines. For example, @bulk_select_controller.js is a case where server-side solutions would degrade the user experience significantly. When bulk-selecting entries, client-side solutions are the way to go and Stimulus provides the right toolset to achieve this.
The Hotwire suite (Turbo/Stimulus) works very well with these native elements and we optimize for this.
### Convention 6: Use Minitest + Fixtures for testing, minimize fixtures
### Convention 4: Optimize for simplicitly and clarity
All code should maximize readability and simplicity.
- Prioritize good OOP domain design over performance
- Only focus on performance for critical and global areas of the codebase; otherwise, don't sweat the small stuff.
- Example 1: be mindful of loading large data payloads in global layouts
- Example 2: Avoid N+1 queries
### Convention 5: Use Minitest + Fixtures for testing, minimize fixtures
Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability.
- Always use Minitest and fixtures for testing.
- Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed.
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [balance_calculator_test.rb](mdc:test/models/account/balance_calculator_test.rb)
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb)
- Take a minimal approach to testing—only test the absolutely critical code paths that will significantly increase developer confidence
### Convention 7: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
#### Convention 5a: Write minimal, effective tests
- Use system tests sparingly as they increase the time to complete the test suite
- Only write tests for critical and important code paths
- Write tests as you go, when required
- Take a practical approach to testing. Tests are effective when their presence _significantly increases confidence in the codebase_.
Below are examples of necessary vs. unnecessary tests:
```rb
# GOOD!!
# Necessary test - in this case, we're testing critical domain business logic
test "syncs balances" do
Account::Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
@account.expects(:start_date).returns(2.days.ago.to_date)
Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
[
Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
]
)
assert_difference "@account.balances.count", 2 do
Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances
end
end
# BAD!!
# Unnecessary test - in this case, this is simply testing ActiveRecord's functionality
test "saves balance" do
balance_record = Account::Balance.new(balance: 100, currency: "USD")
assert balance_record.save
end
```
### Convention 6: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
- Enforce `null` checks, unique indexes, and other simple validations in the DB
- ActiveRecord validations _may_ mirror the DB level ones, but not 100% necessary. These are for convenience when error handling in forms. Always prefer client-side form validation when possible.
- Complex validations and business logic should remain in ActiveRecord
- Complex validations and business logic should remain in ActiveRecord

View File

@@ -1,6 +1,7 @@
---
description: This rule explains the system architecture and data flow of the Rails app
globs: *
alwaysApply: true
---
This file outlines how the codebase is structured and how data flows through the app.
@@ -110,12 +111,12 @@ Below are brief descriptions of each type of sync in more detail.
### Account Syncs
The most important type of sync is the account sync. It is orchestrated by the account [syncer.rb](mdc:app/models/account/syncer.rb), and performs a few important tasks:
The most important type of sync is the account sync. It is orchestrated by the account's `sync_data` method, which performs a few important tasks:
- Auto-matches transfer records for the account
- Calculates holdings and balances for the account
- Enriches transaction data
- Converts account balances that are not in the family's preferred currency to the preferred currency
- Calculates daily [balance.rb](mdc:app/models/account/balance.rb) records for the account from `account.start_date` to `Date.current` using [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb)
- Balances are dependent on the calculation of [holding.rb](mdc:app/models/account/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb)
- Enriches transaction data if enabled by user
An account sync happens every time an [entry.rb](mdc:app/models/account/entry.rb) is updated.
@@ -131,4 +132,125 @@ A Plaid Item sync is an ETL (extract, transform, load) operation:
A family sync happens once daily via [auto_sync.rb](mdc:app/controllers/concerns/auto_sync.rb). A family sync is an "orchestrator" of Account and Plaid Item syncs.
## Data Providers
The Maybe app utilizes several 3rd party data services to calculate historical account balances, enrich data, and more. Since the app can be run in both "hosted" and "self hosted" mode, this means that data providers are _optional_ for self hosted users and must be configured.
Because of this optionality, data providers must be configured at _runtime_ through [registry.rb](mdc:app/models/provider/registry.rb) utilizing [setting.rb](mdc:app/models/setting.rb) for runtime parameters like API keys:
There are two types of 3rd party data in the Maybe app:
1. "Concept" data
2. One-off data
### "Concept" data
Since the app is self hostable, users may prefer using different providers for generic data like exchange rates and security prices. When data is generic enough where we can easily swap out different providers, we call it a data "concept".
Each "concept" has an interface defined in the `app/models/provider/concepts` directory.
```
app/models/
exchange_rate/
provided.rb # <- Responsible for selecting the concept provider from the registry
provider.rb # <- Base provider class
provider/
registry.rb <- Defines available providers by concept
concepts/
exchange_rate.rb <- defines the interface required for the exchange rate concept
synth.rb # <- Concrete provider implementation
```
### One-off data
For data that does not fit neatly into a "concept", an interface is not required and the concrete provider may implement ad-hoc methods called directly in code. For example, the [synth.rb](mdc:app/models/provider/synth.rb) provider has a `usage` method that is only applicable to this specific provider. This should be called directly without any abstractions:
```rb
class SomeModel < Application
def synth_usage
Provider::Registry.get_provider(:synth)&.usage
end
end
```
## "Provided" Concerns
In general, domain models should not be calling [registry.rb](mdc:app/models/provider/registry.rb) directly. When 3rd party data is required for a domain model, we use the `Provided` concern within that model's namespace. This concern is primarily responsible for:
- Choosing the provider to use for this "concept"
- Providing convenience methods on the model for accessing data
For example, [exchange_rate.rb](mdc:app/models/exchange_rate.rb) has a [provided.rb](mdc:app/models/exchange_rate/provided.rb) concern with the following convenience methods:
```rb
module ExchangeRate::Provided
extend ActiveSupport::Concern
class_methods do
def provider
registry = Provider::Registry.for_concept(:exchange_rates)
registry.get_provider(:synth)
end
def find_or_fetch_rate(from:, to:, date: Date.current, cache: true)
# Implementation
end
def sync_provider_rates(from:, to:, start_date:, end_date: Date.current)
# Implementation
end
end
end
```
This exposes a generic access pattern where the caller does not care _which_ provider has been chosen for the concept of exchange rates and can get a predictable response:
```rb
def access_patterns_example
# Call exchange rate provider directly
ExchangeRate.provider.fetch_exchange_rate(from: "USD", to: "CAD", date: Date.current)
# Call convenience method
ExchangeRate.sync_provider_rates(from: "USD", to: "CAD", start_date: 2.days.ago.to_date)
end
```
## Concrete provider implementations
Each 3rd party data provider should have a class under the `Provider::` namespace that inherits from `Provider` and returns `with_provider_response`, which will return a `Provider::ProviderResponse` object:
```rb
class ConcreteProvider < Provider
def fetch_some_data
with_provider_response do
ExampleData.new(
example: "data"
)
end
end
end
```
The `with_provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible:
```rb
class ConcreteProvider < Provider
def fetch_some_data
with_provider_response do
data = nil
# Raise an error if data cannot be returned
raise ProviderError.new("Could not find the data you need") if data.nil?
data
end
end
end
```

View File

@@ -1,13 +1,22 @@
---
description: This file describes Maybe's design system and how views should be styled
globs: app/views/**,app/helpers/**,app/javascript/controllers/**
alwaysApply: true
---
Use this rule whenever you are writing html, css, or even styles in Stimulus controllers that use D3.js.
Use the rules below when:
- You are writing HTML
- You are writing CSS
- You are writing styles in a JavaScript Stimulus controller
## Rules for AI (mandatory)
The codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css)
- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives and tokens we use in the codebase
- Always generate semantic HTML
- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase
- Always prefer using the functional "tokens" defined in @maybe-design-system.css when possible.
- Example 1: use `text-primary` rather than `text-primary`
- Example 2: use `bg-container` rather than `bg-white`
- Example 3: use `border border-primary` rather than `border border-gray-200`
- Never create new styles in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) or [application.css](mdc:app/assets/tailwind/application.css) without explicitly receiving permission to do so
- Always favor the "utility first" Tailwind approach. Reusable style classes should not be created often. Code should be reused primarily through ERB partials.
- Always prefer using the utility "tokens" defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) when possible. For example, use `text-primary` rather than `text-gray-900`.
- Always generate semantic HTML

View File

@@ -1,4 +1,15 @@
version: "3"
x-db-env: &db_env
POSTGRES_USER: postgres
POSTGRES_DB: postgres
POSTGRES_PASSWORD: postgres
x-rails-env: &rails_env
DB_HOST: db
HOST: "0.0.0.0"
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
BUNDLE_PATH: /bundle
REDIS_URL: redis://redis:6379/1
services:
app:
@@ -16,32 +27,41 @@ services:
command: sleep infinity
environment:
DB_HOST: db
HOST: "0.0.0.0"
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
BUNDLE_PATH: /bundle
<<: *rails_env
depends_on:
- db
- redis
worker:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
command: bundle exec sidekiq
restart: unless-stopped
environment:
<<: *rails_env
depends_on:
- redis
redis:
image: redis:latest
ports:
- "6379:6379"
restart: unless-stopped
volumes:
- redis-data:/data
db:
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: postgres
POSTGRES_DB: postgres
POSTGRES_PASSWORD: postgres
<<: *db_env
ports:
- "5432:5432"
volumes:
postgres-data:
redis-data:
bundle_cache:

View File

@@ -1,20 +1,31 @@
# ================================ PLEASE READ ==========================================
# This file outlines all the possible environment variables supported by the Maybe app.
#
# This includes several features that are for our "hosted" version of Maybe, which most
# open-source contributors won't need.
# ================================ PLEASE READ ===========================================================
# This file outlines all the possible environment variables supported by the Maybe app for self hosting.
#
# If you are developing locally, you should be referencing `.env.local.example` instead.
# =======================================================================================
# If you're a developer setting up your local environment, please use `.env.local.example` instead.
# ========================================================================================================
# Required self-hosting vars
# --------------------------------------------------------------------------------------------------------
# Enables self hosting features (should be set to true unless you know what you're doing)
SELF_HOSTED=true
# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base)
# Has to be a random string, generated eg. by running `openssl rand -hex 64`
SECRET_KEY_BASE=secret-value
# Optional self-hosting vars
# --------------------------------------------------------------------------------------------------------
# Optional: Synth API Key for exchange rates + stock prices
# (you can also set this in your self-hosted settings page)
# Get it here: https://synthfinance.com/
SYNTH_API_KEY=
# Custom port config
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
PORT=3000
# Exchange Rate & Stock Pricing API
# This is used to convert between different currencies in the app. In addition, it fetches global stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
SYNTH_API_KEY=
# SMTP Configuration
# This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports).
# Resend.com is a good option that offers a free tier for sending emails.
@@ -37,60 +48,20 @@ POSTGRES_USER=postgres
# This is the domain that your Maybe instance will be hosted at. It is used to generate links in emails and other places.
APP_DOMAIN=
## Error and Performance Monitoring
# The app uses Sentry to monitor errors and performance. In reality, you likely don't need this unless you're deploying Maybe to many users.
SENTRY_DSN=
# If enabled, an invite code generated by `rake invites:create` is required to sign up as a new user.
# This is useful for controlling who can sign up for your Maybe instance.
REQUIRE_INVITE_CODE=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
HOSTING_PLATFORM=localhost
# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base)
# Has to be a random string, generated eg. by running `openssl rand -hex 64`
SECRET_KEY_BASE=secret-value
# Disable enforcing SSL connections
# DISABLE_SSL=true
# ======================================================================================================
# Upgrades Module - responsible for triggering upgrade alerts, prompts, and auto-upgrade functionality
# ======================================================================================================
#
# UPGRADES_ENABLED: Enables Upgrader class functionality.
# UPGRADES_MODE: Controls how the app will upgrade. `manual` means the user must manually upgrade the app. `auto` means the app will upgrade automatically (great for self-hosting)
# UPGRADES_TARGET: Controls what the app will upgrade to. `release` means the app will upgrade to the latest release. `commit` means the app will upgrade to the latest commit.
#
UPGRADES_ENABLED=false # unless editing the flow, you should keep this `false` locally in development
UPGRADES_MODE=manual # `manual` or `auto`
UPGRADES_TARGET=release # `release` or `commit`
# ======================================================================================================
# Git Repository Module - responsible for fetching latest commit data for upgrades
# ======================================================================================================
#
GITHUB_REPO_OWNER=maybe-finance
GITHUB_REPO_NAME=maybe
GITHUB_REPO_BRANCH=main
# ======================================================================================================
# Active Storage Configuration - responsible for storing file uploads
# ======================================================================================================
#
# * Defaults to disk storage but you can also use Amazon S3, Google Cloud Storage, or Microsoft Azure Storage.
# * Defaults to disk storage but you can also use Amazon S3 or Cloudflare R2
# * Set the appropriate environment variables to use these services.
# * Ensure libvips is installed on your system for image processing - https://github.com/libvips/libvips
#
# Amazon S3
# ==========
# ACTIVE_STORAGE_SERVICE=amazon
# ACTIVE_STORAGE_SERVICE=amazon <- Enables Amazon S3 storage
# S3_ACCESS_KEY_ID=
# S3_SECRET_ACCESS_KEY=
# S3_REGION= # defaults to `us-east-1` if not set
@@ -98,26 +69,9 @@ GITHUB_REPO_BRANCH=main
#
# Cloudflare R2
# =============
# ACTIVE_STORAGE_SERVICE=cloudflare
# ACTIVE_STORAGE_SERVICE=cloudflare <- Enables Cloudflare R2 storage
# 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=
# ======================================================================================================
# Plaid Configuration
# ======================================================================================================
#
PLAID_CLIENT_ID=
PLAID_SECRET=
PLAID_ENV=
PLAID_EU_CLIENT_ID=
PLAID_EU_SECRET=

View File

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

View File

@@ -1,3 +1,5 @@
SELF_HOSTED=false
# ================
# Data Providers
# ---------------------------------------------------------------------------------

View File

@@ -1,31 +1,61 @@
---
name: Bug report
about: Create a report to help us improve
about: Open a bug report when you experience broken functionality within the latest
version of the Maybe app
title: 'Bug: [Add descriptive title here]'
labels: ''
assignees: ''
---
**Where did this bug occur? (required)**
## Before you start (required)
- [ ] I am a self-hosted user reporting a bug from my self hosted app
- [ ] I am a user of Maybe's paid app
### General checklist
_Please note, if you are reporting a bug with sensitive data, please open an Intercom chat from within the app for help_
- [ ] I have removed personal / sensitive data from screenshots and logs
- [ ] I have searched [existing issues](https://github.com/maybe-finance/maybe/issues?q=is:issue) and [discussions](https://github.com/maybe-finance/maybe/discussions) to ensure this is not a duplicate issue
### How are you using Maybe?
- [ ] I am a paying Maybe customer (hosted version)
- Paying Maybe users can also open requests in Intercom (if there is sensitive info involved)
- [ ] I am a self-hosted user
### Self hoster checklist
_Paying, hosted users should delete this entire section._
If you are a self-hosted user, please complete all of the information below. Issues with incomplete information will be marked as `Needs Info` to help our small team prioritize bug fixes.
- Self hosted app commit SHA (find in user menu): [enter commit sha here]
- [ ] I have confirmed that my app's commit is the latest version of Maybe
- Where are you hosting?
- [ ] Render
- [ ] Docker Compose
- [ ] Umbrel
- [ ] Other (please specify)
---
## Bug description
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
### To Reproduce
Be as specific as possible so Maybe maintainers can quickly reproduce the bug you're experiencing.
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
### Expected behavior
**Screenshots / Recordings**
If applicable, add screenshots or short video recordings to help show the bug in more detail.
What is the intended behavior that you would expect?
### Screenshots and/or recordings
We highly recommend providing additional context with screenshots and/or screen recordings. This will _significantly_ improve the chances of the bug being addressed and fixed quickly.

View File

@@ -7,15 +7,33 @@ assignees: ''
---
**PLEASE READ before opening an issue:**
## Before you start (required)
- Is this a feature request? Please [open a feature request discussion](https://github.com/maybe-finance/maybe/discussions/new?category=feature-requests).
- Do you need help or have a question? Please [open a discussion](https://github.com/maybe-finance/maybe/discussions/new/choose) or [join our Discord](https://link.maybe.co/discord) and post to the "help" channel.
### Is this a bug?
----------------------
A bug is _broken functionality_ of the app (i.e. it prevents you from using the app). For bugs, please use the ["Bug Report" template](https://github.com/maybe-finance/maybe/issues) instead.
**Is this issue related to a problem? Please describe.**
### Is this a bug with _sensitive info_?
**Describe the work that needs to be done to address this issue**
If you are a _paying_ Maybe user, you can open a support request in Intercom.
**Additional context**
### Is this a feature request?
A feature request is functionality that you would like that is not already on our [Roadmap](https://github.com/maybe-finance/maybe/wiki/Roadmap).
All feature requests should be opened in a [Feature request Discussion](https://github.com/maybe-finance/maybe/discussions/categories/feature-requests).
Be sure to search existing discussions prior to opening a new feature request.
### Is this related to Docker and/or hosting for self hosting?
If you are having a Docker configuration issue, please do not open a Github issue unless you've identified a bug in our Dockerfile. To get help with self hosting, there are several options:
- **First**: Read our [Docker hosting guide](https://github.com/maybe-finance/maybe/tree/main/docs/hosting/docker.md) and follow it step-by-step
- Open a [Docker Discussion](https://github.com/maybe-finance/maybe/discussions/categories/docker-compose-hosting)
---
## Issue description
If your issue does not fall into the categories above, please provide a **descriptive and complete** overview of your issue.

View File

@@ -1,6 +1,13 @@
name: Publish Docker image
on:
workflow_dispatch:
inputs:
ref:
description: 'Git ref (tag or commit SHA) to build'
required: true
type: string
default: 'main'
push:
tags:
- 'v*'
@@ -33,6 +40,8 @@ jobs:
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref || github.ref }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -73,3 +82,4 @@ jobs:
provenance: false
# https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images
outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=A multi-arch Docker image for the Maybe Rails app
build-args: BUILD_COMMIT_SHA=${{ github.sha }}

7
.gitignore vendored
View File

@@ -11,7 +11,6 @@
# Ignore all environment files (except templates).
/.env*
!/.env*.erb
!.env.test
!.env*.example
# Ignore all logfiles and tempfiles.
@@ -63,6 +62,10 @@ gcp-storage-keyfile.json
coverage
.cursorrules
.cursor/rules/structure.mdc
.cursor/rules/agent.mdc
# Ignore node related files
node_modules
node_modules
compose.yml

View File

@@ -9,19 +9,21 @@ WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libvips postgresql-client git
apt-get install --no-install-recommends -y curl libvips postgresql-client
# Set production environment
ARG BUILD_COMMIT_SHA
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development"
BUNDLE_WITHOUT="development" \
BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA}
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apt-get install --no-install-recommends -y build-essential libpq-dev pkg-config
RUN apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config
# Install application gems
COPY .ruby-version Gemfile Gemfile.lock ./

13
Gemfile
View File

@@ -7,6 +7,7 @@ gem "rails", "~> 7.2.2"
# Drivers
gem "pg", "~> 1.5"
gem "redis", "~> 5.4"
# Deployment
gem "puma", ">= 5.0"
@@ -22,17 +23,17 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails"
gem "stimulus-rails"
gem "turbo-rails"
# Temporary pin to commit to fix crypto.randomUUID() errors. Revert this when the change has been released.
gem "hotwire_combobox", github: "josefarias/hotwire_combobox", ref: "b827048a8305e1115d5f96931ba1c9750d1e59fc"
gem "hotwire_combobox"
# Background Jobs
gem "good_job"
gem "sidekiq"
# Error logging
gem "stackprof"
gem "vernier"
gem "rack-mini-profiler"
gem "sentry-ruby"
gem "sentry-rails"
gem "sentry-sidekiq"
gem "logtail-rails"
# Active Storage
@@ -57,6 +58,10 @@ gem "intercom-rails"
gem "plaid"
gem "rotp", "~> 6.3"
gem "rqrcode", "~> 2.2"
gem "activerecord-import"
# AI
gem "ruby-openai"
group :development, :test do
gem "debug", platforms: %i[mri windows]

View File

@@ -1,14 +1,3 @@
GIT
remote: https://github.com/josefarias/hotwire_combobox.git
revision: b827048a8305e1115d5f96931ba1c9750d1e59fc
ref: b827048a8305e1115d5f96931ba1c9750d1e59fc
specs:
hotwire_combobox (0.3.2)
platform_agent (>= 1.0.1)
rails (>= 7.0.7.2)
stimulus-rails (>= 1.2)
turbo-rails (>= 1.2)
GIT
remote: https://github.com/maybe-finance/lucide-rails.git
revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0
@@ -72,6 +61,8 @@ GEM
activemodel (= 7.2.2.1)
activesupport (= 7.2.2.1)
timeout (>= 0.4.0)
activerecord-import (2.1.0)
activerecord (>= 4.2)
activestorage (7.2.2.1)
actionpack (= 7.2.2.1)
activejob (= 7.2.2.1)
@@ -92,15 +83,17 @@ GEM
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.1043.0)
aws-sdk-core (3.217.0)
ast (2.4.3)
aws-eventstream (1.3.2)
aws-partitions (1.1073.0)
aws-sdk-core (3.221.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.97.0)
logger
aws-sdk-kms (1.99.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.177.0)
@@ -124,7 +117,7 @@ GEM
bindex (0.8.1)
bootsnap (1.18.4)
msgpack (~> 1.2)
brakeman (7.0.0)
brakeman (7.0.2)
racc
builder (3.3.0)
capybara (3.40.0)
@@ -146,7 +139,7 @@ GEM
bigdecimal
rexml
crass (1.0.6)
csv (3.3.2)
csv (3.3.3)
date (3.4.1)
debug (1.10.0)
irb (~> 1.10)
@@ -165,8 +158,7 @@ GEM
rubocop (>= 1)
smart_properties
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
event_stream_parser (1.0.0)
faker (3.5.1)
i18n (>= 1.8.11, < 2)
faraday (2.12.2)
@@ -177,7 +169,7 @@ GEM
multipart-post (~> 2.0)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
faraday-retry (2.2.1)
faraday-retry (2.3.1)
faraday (~> 2.0)
ffi (1.17.1-aarch64-linux-gnu)
ffi (1.17.1-aarch64-linux-musl)
@@ -187,18 +179,8 @@ GEM
ffi (1.17.1-x86_64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
ffi (1.17.1-x86_64-linux-musl)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (4.9.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.2)
highline (3.1.2)
reline
@@ -206,9 +188,14 @@ GEM
actioncable (>= 7.0.0)
listen (>= 3.0.0)
railties (>= 7.0.0)
hotwire_combobox (0.4.0)
platform_agent (>= 1.0.1)
rails (>= 7.0.7.2)
stimulus-rails (>= 1.2)
turbo-rails (>= 1.2)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.14)
i18n-tasks (1.0.15)
activesupport (>= 4.0.2)
ast (>= 2.1.0)
erubi
@@ -217,6 +204,7 @@ GEM
parser (>= 3.2.2.1)
rails-i18n
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.8, >= 1.8.1)
terminal-table (>= 1.5.1)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
@@ -237,21 +225,22 @@ GEM
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.10.1)
json (2.10.2)
jwt (2.10.1)
base64
language_server-protocol (3.17.0.4)
launchy (3.1.0)
launchy (3.1.1)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
lint_roller (1.1.0)
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.6.6)
logtail (0.1.15)
logger (1.7.0)
logtail (0.1.17)
msgpack (~> 1.0)
logtail-rack (0.2.6)
logtail (~> 0.1)
@@ -272,49 +261,49 @@ GEM
net-smtp
marcel (1.0.4)
matrix (0.4.2)
mini_magick (5.1.2)
mini_magick (5.2.0)
benchmark
logger
mini_mime (1.1.5)
minitest (5.25.4)
minitest (5.25.5)
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
multipart-post (2.4.1)
net-http (0.6.0)
uri
net-imap (0.5.5)
net-imap (0.5.6)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.0)
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.2-aarch64-linux-gnu)
nokogiri (1.18.6-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.2-aarch64-linux-musl)
nokogiri (1.18.6-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.2-arm-linux-gnu)
nokogiri (1.18.6-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.2-arm-linux-musl)
nokogiri (1.18.6-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.2-arm64-darwin)
nokogiri (1.18.6-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.2-x86_64-darwin)
nokogiri (1.18.6-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.2-x86_64-linux-gnu)
nokogiri (1.18.6-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.2-x86_64-linux-musl)
nokogiri (1.18.6-x86_64-linux-musl)
racc (~> 1.4)
octokit (9.2.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (9.3.3)
pagy (9.3.4)
parallel (1.26.3)
parser (3.3.7.0)
parser (3.3.7.2)
ast (~> 2.4.1)
racc
pg (1.5.9)
@@ -327,7 +316,7 @@ GEM
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
prism (1.3.0)
prism (1.4.0)
propshaft (1.1.0)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
@@ -339,9 +328,8 @@ GEM
public_suffix (6.0.1)
puma (6.6.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.10)
rack (3.1.12)
rack-mini-profiler (3.3.1)
rack (>= 1.2.0)
rack-session (2.1.0)
@@ -391,11 +379,15 @@ GEM
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
rbs (3.8.1)
rbs (3.9.1)
logger
rdoc (6.12.0)
rdoc (6.13.0)
psych (>= 4.0.0)
redcarpet (3.6.0)
redcarpet (3.6.1)
redis (5.4.0)
redis-client (>= 0.22.0)
redis-client (0.24.0)
connection_pool
regexp_parser (2.10.0)
reline (0.6.0)
io-console (~> 0.5)
@@ -405,41 +397,44 @@ GEM
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
rqrcode_core (1.2.0)
rubocop (1.71.0)
rubocop (1.74.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.36.2, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.38.0)
parser (>= 3.3.1.0)
rubocop-minitest (0.36.0)
rubocop (>= 1.61, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-performance (1.23.1)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.29.1)
rubocop-ast (1.41.0)
parser (>= 3.3.7.2)
rubocop-performance (1.24.0)
lint_roller (~> 1.1)
rubocop (>= 1.72.1, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.30.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails-omakase (1.0.0)
rubocop
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.23.9)
rubocop (>= 1.72.1, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-lsp (0.23.12)
language_server-protocol (~> 3.17.0)
prism (>= 1.2, < 2.0)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.4.0)
ruby-lsp (>= 0.23.0, < 0.24.0)
ruby-openai (8.1.0)
event_stream_parser (>= 0.3.0, < 2.0.0)
faraday (>= 1)
faraday-multipart (>= 1)
ruby-progressbar (1.13.0)
ruby-vips (2.2.3)
ffi (~> 1.12)
@@ -450,18 +445,27 @@ GEM
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
securerandom (0.4.1)
selenium-webdriver (4.29.1)
selenium-webdriver (4.31.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sentry-rails (5.22.4)
sentry-rails (5.23.0)
railties (>= 5.0)
sentry-ruby (~> 5.22.4)
sentry-ruby (5.22.4)
sentry-ruby (~> 5.23.0)
sentry-ruby (5.23.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.23.0)
sentry-ruby (~> 5.23.0)
sidekiq (>= 3.0)
sidekiq (8.0.2)
connection_pool (>= 2.5.0)
json (>= 2.9.0)
logger (>= 1.6.2)
rack (>= 3.1.0)
redis-client (>= 0.23.2)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
@@ -469,44 +473,44 @@ GEM
simplecov-html (0.13.1)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11813)
stackprof (0.2.27)
sorbet-runtime (0.5.11953)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.3)
stripe (13.4.1)
tailwindcss-rails (4.0.0)
stringio (3.1.5)
stripe (14.0.0)
tailwindcss-rails (4.2.1)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.0.6)
tailwindcss-ruby (4.0.6-aarch64-linux-gnu)
tailwindcss-ruby (4.0.6-aarch64-linux-musl)
tailwindcss-ruby (4.0.6-arm64-darwin)
tailwindcss-ruby (4.0.6-x86_64-darwin)
tailwindcss-ruby (4.0.6-x86_64-linux-gnu)
tailwindcss-ruby (4.0.6-x86_64-linux-musl)
tailwindcss-ruby (4.0.15)
tailwindcss-ruby (4.0.15-aarch64-linux-gnu)
tailwindcss-ruby (4.0.15-aarch64-linux-musl)
tailwindcss-ruby (4.0.15-arm64-darwin)
tailwindcss-ruby (4.0.15-x86_64-darwin)
tailwindcss-ruby (4.0.15-x86_64-linux-gnu)
tailwindcss-ruby (4.0.15-x86_64-linux-musl)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
thor (1.3.2)
timeout (0.4.3)
turbo-rails (2.0.11)
actionpack (>= 6.0.0)
railties (>= 6.0.0)
turbo-rails (2.0.13)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.2)
uri (1.0.3)
useragent (0.16.11)
vcr (6.3.1)
base64
vernier (1.7.0)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.25.0)
webmock (3.25.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@@ -517,22 +521,20 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.1)
zeitwerk (2.7.2)
PLATFORMS
aarch64-linux
aarch64-linux-gnu
aarch64-linux-musl
arm-linux
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86_64-darwin
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
activerecord-import
aws-sdk-s3 (~> 1.177.0)
bcrypt (~> 3.1)
benchmark-ips
@@ -548,9 +550,8 @@ DEPENDENCIES
faraday
faraday-multipart
faraday-retry
good_job
hotwire-livereload
hotwire_combobox!
hotwire_combobox
i18n-tasks
image_processing (>= 1.2)
importmap-rails
@@ -571,21 +572,25 @@ DEPENDENCIES
rails (~> 7.2.2)
rails-settings-cached
redcarpet
redis (~> 5.4)
rotp (~> 6.3)
rqrcode (~> 2.2)
rubocop-rails-omakase
ruby-lsp-rails
ruby-openai
selenium-webdriver
sentry-rails
sentry-ruby
sentry-sidekiq
sidekiq
simplecov
stackprof
stimulus-rails
stripe
tailwindcss-rails
turbo-rails
tzinfo-data
vcr
vernier
web-console
webmock

View File

@@ -1,3 +1,3 @@
web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
css: bundle exec bin/rails tailwindcss:watch
worker: bundle exec good_job start
worker: bundle exec sidekiq

View File

@@ -6,9 +6,6 @@
<b>Get
involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybefinance.com) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
_If you're looking for the previous React codebase, you can find it
at [maybe-finance/maybe-archive](https://github.com/maybe-finance/maybe-archive)._
## Backstory
We spent the better part of 2021/2022 building a personal finance + wealth
@@ -29,9 +26,8 @@ and eventually offer a hosted version of the app for a small monthly fee.
There are 3 primary ways to use the Maybe app:
1. Managed (easiest) - _coming soon..._
2. [One-click deploy](docs/hosting/one-click-deploy.md)
3. [Self-host with Docker](docs/hosting/docker.md)
1. Managed (easiest) - we're in alpha and release invites in our Discord
2. [Self-host with Docker](docs/hosting/docker.md)
## Contributing
@@ -84,37 +80,10 @@ If you'd like multi-currency support, there are a few extra steps to follow.
### Setup Guides
#### Dev Container (optional)
This is 100% optional and meant for devs who don't want to worry about
installing requirements manually for their platform. You can
follow [this guide](https://code.visualstudio.com/docs/devcontainers/containers)
to learn more about Dev Containers.
If you run into `could not connect to server` errors, you may need to change
your `.env`'s `DB_HOST` environment variable value to `db` to point to the
Postgres container.
#### Mac
Please visit
our [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide).
#### Linux
Please visit
our [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide).
#### Windows
Please visit
our [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide).
### Testing Emails
In development, we use `letter_opener` to automatically open emails in your
browser. When an email sends locally, a new browser tab will open with a
preview.
- [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide)
- [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide)
- [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide)
- Dev containers - visit [this guide](https://code.visualstudio.com/docs/devcontainers/containers) to learn more
## Repo Activity

85
app/assets/images/ai.svg Normal file
View File

@@ -0,0 +1,85 @@
<svg width="62" height="68" viewBox="0 0 62 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_f_7620_90382)">
<path d="M15.0109 27.3668C14.8138 11.2848 17.2087 15.4884 28.5797 15.5133L32.8675 15.5228C44.2383 15.5478 46.7179 11.3549 46.9149 27.4368L46.9891 33.5015C47.1861 49.5834 44.7913 53.0249 33.4205 52.9999L29.1325 52.9906C17.7617 52.9656 15.2823 49.5134 15.0852 33.4315L15.0109 27.3668Z" fill="url(#paint0_linear_7620_90382)" fill-opacity="0.15"/>
</g>
<g filter="url(#filter1_i_7620_90382)">
<rect x="15" y="13" width="32" height="32" rx="10.6667" fill="url(#paint1_linear_7620_90382)"/>
<rect x="15" y="13" width="32" height="32" rx="10.6667" fill="white" fill-opacity="0.07" style="mix-blend-mode:plus-lighter"/>
</g>
<g filter="url(#filter2_ii_7620_90382)">
<rect x="16.7773" y="14.7778" width="28.4444" height="28.4444" rx="8.88889" fill="url(#paint2_linear_7620_90382)"/>
<path d="M36.1921 22.073C36.6039 22.0652 36.9439 22.3927 36.9517 22.8044C36.9786 24.2352 37.0273 25.6596 37.0958 27.088C37.1155 27.4993 36.7981 27.8487 36.3868 27.8684C35.9755 27.8881 35.6261 27.5707 35.6063 27.1594C35.5372 25.7174 35.488 24.2785 35.4607 22.8325C35.453 22.4208 35.7804 22.0807 36.1921 22.073Z" fill="#141414"/>
<path d="M36.1921 22.073C36.6039 22.0652 36.9439 22.3927 36.9517 22.8044C36.9786 24.2352 37.0273 25.6596 37.0958 27.088C37.1155 27.4993 36.7981 27.8487 36.3868 27.8684C35.9755 27.8881 35.6261 27.5707 35.6063 27.1594C35.5372 25.7174 35.488 24.2785 35.4607 22.8325C35.453 22.4208 35.7804 22.0807 36.1921 22.073Z" fill="url(#paint3_linear_7620_90382)"/>
<path d="M30.0884 22.7413C30.3247 22.4041 30.2428 21.9392 29.9056 21.7029C29.5684 21.4666 29.1034 21.5484 28.8671 21.8857C28.2555 22.7586 27.6031 23.6183 26.9349 24.4988C26.6795 24.8354 26.4217 25.1751 26.1631 25.5196C26.1629 25.4497 26.1627 25.379 26.1622 25.3074C26.158 24.7 26.1364 24.0511 26.0192 23.3998C25.9463 22.9946 25.5586 22.7251 25.1533 22.7981C24.7481 22.871 24.4787 23.2587 24.5516 23.6639C24.6454 24.185 24.667 24.7297 24.671 25.3176C24.672 25.4628 24.6719 25.6127 24.6718 25.7658C24.6714 26.2118 24.6709 26.6845 24.6985 27.1431C24.7046 27.2459 24.7313 27.3426 24.7744 27.4294C24.0609 28.4557 23.3903 29.5154 22.8297 30.615C22.8221 30.6299 22.8109 30.6511 22.7968 30.6775C22.7112 30.8389 22.5209 31.1974 22.4089 31.5427C22.3452 31.7392 22.2741 32.0229 22.3076 32.3158C22.3255 32.4722 22.3762 32.6577 22.4979 32.8323C22.6242 33.0135 22.7989 33.142 22.9963 33.2166C24.2085 33.6749 25.5494 33.7216 26.818 33.625C27.848 33.5466 28.8878 33.3675 29.8142 33.2078C30.0261 33.1713 30.2321 33.1358 30.4306 33.1028C30.8368 33.0352 31.1113 32.6512 31.0438 32.245C30.9762 31.8388 30.5921 31.5643 30.1859 31.6318C29.9702 31.6677 29.7524 31.7052 29.5328 31.743C28.6101 31.9017 27.6572 32.0656 26.7048 32.1381C25.6662 32.2172 24.6958 32.1801 23.852 31.9317C23.9193 31.7487 24.0152 31.566 24.0967 31.4107C24.1185 31.3691 24.1393 31.3294 24.1582 31.2923C24.909 29.8195 25.8865 28.397 26.9361 26.9776C27.3129 26.4681 27.7032 25.9536 28.0947 25.4375C28.7774 24.5377 29.4637 23.6329 30.0884 22.7413Z" fill="#141414"/>
<path d="M30.0884 22.7413C30.3247 22.4041 30.2428 21.9392 29.9056 21.7029C29.5684 21.4666 29.1034 21.5484 28.8671 21.8857C28.2555 22.7586 27.6031 23.6183 26.9349 24.4988C26.6795 24.8354 26.4217 25.1751 26.1631 25.5196C26.1629 25.4497 26.1627 25.379 26.1622 25.3074C26.158 24.7 26.1364 24.0511 26.0192 23.3998C25.9463 22.9946 25.5586 22.7251 25.1533 22.7981C24.7481 22.871 24.4787 23.2587 24.5516 23.6639C24.6454 24.185 24.667 24.7297 24.671 25.3176C24.672 25.4628 24.6719 25.6127 24.6718 25.7658C24.6714 26.2118 24.6709 26.6845 24.6985 27.1431C24.7046 27.2459 24.7313 27.3426 24.7744 27.4294C24.0609 28.4557 23.3903 29.5154 22.8297 30.615C22.8221 30.6299 22.8109 30.6511 22.7968 30.6775C22.7112 30.8389 22.5209 31.1974 22.4089 31.5427C22.3452 31.7392 22.2741 32.0229 22.3076 32.3158C22.3255 32.4722 22.3762 32.6577 22.4979 32.8323C22.6242 33.0135 22.7989 33.142 22.9963 33.2166C24.2085 33.6749 25.5494 33.7216 26.818 33.625C27.848 33.5466 28.8878 33.3675 29.8142 33.2078C30.0261 33.1713 30.2321 33.1358 30.4306 33.1028C30.8368 33.0352 31.1113 32.6512 31.0438 32.245C30.9762 31.8388 30.5921 31.5643 30.1859 31.6318C29.9702 31.6677 29.7524 31.7052 29.5328 31.743C28.6101 31.9017 27.6572 32.0656 26.7048 32.1381C25.6662 32.2172 24.6958 32.1801 23.852 31.9317C23.9193 31.7487 24.0152 31.566 24.0967 31.4107C24.1185 31.3691 24.1393 31.3294 24.1582 31.2923C24.909 29.8195 25.8865 28.397 26.9361 26.9776C27.3129 26.4681 27.7032 25.9536 28.0947 25.4375C28.7774 24.5377 29.4637 23.6329 30.0884 22.7413Z" fill="url(#paint4_linear_7620_90382)"/>
<path d="M36.2391 34.7581C36.3664 34.3664 36.1522 33.9458 35.7606 33.8185C35.369 33.6911 34.9483 33.9054 34.821 34.297C34.6438 34.842 34.4106 35.256 34.12 35.541C33.8419 35.8137 33.4787 36.0015 32.9619 36.0499C32.1922 36.1221 31.4116 35.7978 31.071 35.2344C30.858 34.882 30.3996 34.7691 30.0472 34.9821C29.6948 35.1951 29.5818 35.6535 29.7949 36.0059C30.5079 37.1855 31.9259 37.6448 33.1011 37.5346C33.9477 37.4552 34.6338 37.1258 35.1641 36.6056C35.6819 36.0978 36.0163 35.4433 36.2391 34.7581Z" fill="#141414"/>
<path d="M36.2391 34.7581C36.3664 34.3664 36.1522 33.9458 35.7606 33.8185C35.369 33.6911 34.9483 33.9054 34.821 34.297C34.6438 34.842 34.4106 35.256 34.12 35.541C33.8419 35.8137 33.4787 36.0015 32.9619 36.0499C32.1922 36.1221 31.4116 35.7978 31.071 35.2344C30.858 34.882 30.3996 34.7691 30.0472 34.9821C29.6948 35.1951 29.5818 35.6535 29.7949 36.0059C30.5079 37.1855 31.9259 37.6448 33.1011 37.5346C33.9477 37.4552 34.6338 37.1258 35.1641 36.6056C35.6819 36.0978 36.0163 35.4433 36.2391 34.7581Z" fill="url(#paint5_linear_7620_90382)"/>
</g>
<defs>
<filter id="filter0_f_7620_90382" x="0.937778" y="0.937778" width="60.1244" height="66.1244" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="7.03111" result="effect1_foregroundBlur_7620_90382"/>
</filter>
<filter id="filter1_i_7620_90382" x="15" y="13" width="32" height="32" 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/>
<feGaussianBlur stdDeviation="0.49869"/>
<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="effect1_innerShadow_7620_90382"/>
</filter>
<filter id="filter2_ii_7620_90382" x="16.7773" y="13.8889" width="28.4453" height="30.2222" 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="-0.888889"/>
<feGaussianBlur stdDeviation="0.888889"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.980392 0 0 0 0 0.309804 0 0 0 0 0.67451 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_7620_90382"/>
<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="0.888889"/>
<feGaussianBlur stdDeviation="0.888889"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.835294 0 0 0 0 1 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="effect1_innerShadow_7620_90382" result="effect2_innerShadow_7620_90382"/>
</filter>
<linearGradient id="paint0_linear_7620_90382" x1="30.1185" y1="16.1417" x2="33.7041" y2="53.1754" gradientUnits="userSpaceOnUse">
<stop stop-color="#22CCEE"/>
<stop offset="0.274483" stop-color="#1570EF"/>
<stop offset="0.629793" stop-color="#6927DA"/>
<stop offset="1" stop-color="#F23E94"/>
</linearGradient>
<linearGradient id="paint1_linear_7620_90382" x1="31" y1="13" x2="31" y2="45" gradientUnits="userSpaceOnUse">
<stop stop-color="#22CCEE"/>
<stop offset="0.274483" stop-color="#1570EF"/>
<stop offset="0.629793" stop-color="#6927DA"/>
<stop offset="1" stop-color="#F23E94"/>
</linearGradient>
<linearGradient id="paint2_linear_7620_90382" x1="30.9996" y1="23.6667" x2="30.9996" y2="43.2222" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="0.3" stop-color="#F7F7F7"/>
</linearGradient>
<linearGradient id="paint3_linear_7620_90382" x1="32.5419" y1="21.0645" x2="28.4008" y2="36.5193" gradientUnits="userSpaceOnUse">
<stop stop-color="#22CCEE"/>
<stop offset="0.274483" stop-color="#1570EF"/>
<stop offset="0.629793" stop-color="#6927DA"/>
<stop offset="1" stop-color="#F23E94"/>
</linearGradient>
<linearGradient id="paint4_linear_7620_90382" x1="32.5419" y1="21.0645" x2="28.4008" y2="36.5193" gradientUnits="userSpaceOnUse">
<stop stop-color="#22CCEE"/>
<stop offset="0.274483" stop-color="#1570EF"/>
<stop offset="0.629793" stop-color="#6927DA"/>
<stop offset="1" stop-color="#F23E94"/>
</linearGradient>
<linearGradient id="paint5_linear_7620_90382" x1="32.5419" y1="21.0645" x2="28.4008" y2="36.5193" gradientUnits="userSpaceOnUse">
<stop stop-color="#22CCEE"/>
<stop offset="0.274483" stop-color="#1570EF"/>
<stop offset="0.629793" stop-color="#6927DA"/>
<stop offset="1" stop-color="#F23E94"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -1 +0,0 @@
/* Application styles */

View File

@@ -8,6 +8,35 @@
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@import "./simonweb_pickr.css";
@layer components {
.pcr-app{
position: static !important;
background: none !important;
box-shadow: none !important;
padding: 0 !important;
width: 100% !important;
}
.pcr-color-palette{
height: 12em !important;
width: 21.5rem !important;
}
.pcr-palette{
border-radius: 10px !important;
}
.pcr-palette:before{
border-radius: 10px !important;
}
.pcr-color-chooser{
height: 1.5em !important;
}
.pcr-picker{
height: 20px !important;
width: 20px !important;
}
}
.combobox {
.hw-combobox__main__wrapper,
.hw-combobox__input {
@@ -83,6 +112,30 @@
}
}
.prose--ai-chat {
@apply break-words;
p, li {
@apply text-sm text-primary;
}
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 3px;
}
}
/* Custom scrollbar implementation for Windows browsers */
.windows {
::-webkit-scrollbar {
@@ -112,4 +165,5 @@
&::-webkit-scrollbar-thumb:hover {
background: #a6a6a6;
}
}
}
/* The following Markdown CSS has been removed as requested */

View File

@@ -5,6 +5,8 @@
One-off styling (3rd party overrides, etc.) should be done in the application.css file.
*/
@custom-variant theme-dark (&:where([data-theme=dark], [data-theme=dark] *));
@theme {
/* Font families */
--font-sans: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
@@ -241,83 +243,240 @@
/* Design system color utilities */
@utility text-primary {
@apply text-gray-900;
@variant theme-dark {
@apply text-white;
}
}
@utility text-secondary {
@apply text-gray-500;
@variant theme-dark {
@apply text-gray-400;
}
}
@utility text-subdued {
@apply text-gray-400;
@variant theme-dark {
@apply text-gray-600;
}
}
@utility text-link {
@apply text-blue-600;
@variant theme-dark {
@apply text-blue-500;
}
}
@utility bg-surface {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-black;
}
}
@utility bg-surface-hover {
@apply bg-gray-100;
@variant theme-dark {
@apply bg-gray-800;
}
}
@utility bg-surface-inset {
@apply bg-gray-100;
@variant theme-dark {
@apply bg-gray-900;
}
}
@utility bg-surface-inset-hover {
@apply bg-gray-200;
@variant theme-dark {
@apply bg-gray-800;
}
}
@utility bg-container {
@apply bg-white;
@variant theme-dark {
@apply bg-gray-900;
}
}
@utility bg-container-hover {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-gray-800;
}
}
@utility bg-container-inset {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-gray-800;
}
}
@utility bg-container-inset-hover {
@apply bg-gray-100;
@variant theme-dark {
@apply bg-gray-700;
}
}
@utility bg-inverse {
@apply bg-gray-800;
@variant theme-dark {
@apply bg-white;
}
}
@utility bg-inverse-hover {
@apply bg-gray-700;
@variant theme-dark {
@apply bg-gray-100;
}
}
@utility bg-overlay {
@apply bg-alpha-black-200;
background-color: rgba(var(--color-gray-100), 0.5);
@variant theme-dark {
background-color: var(--color-alpha-black-900);
}
}
@utility border-primary {
@apply border-alpha-black-300;
@variant theme-dark {
@apply border-alpha-white-400;
}
}
@utility border-secondary {
@apply border-alpha-black-200;
@variant theme-dark {
@apply border-alpha-white-300;
}
}
@utility border-tertiary {
@apply border-alpha-black-100;
@variant theme-dark {
@apply border-alpha-white-200;
}
}
@utility border-subdued {
@apply border-alpha-black-50;
@variant theme-dark {
@apply border-alpha-white-100;
}
}
@utility border-solid {
@apply border-black;
@variant theme-dark {
@apply border-white;
}
}
@utility border-destructive {
@apply border-red-500;
@variant theme-dark {
@apply border-red-400;
}
}
/* Foreground Colors */
@utility fg-gray {
@apply text-gray-500;
@variant theme-dark {
@apply text-gray-400;
}
}
@utility fg-contrast {
@apply text-gray-400;
@variant theme-dark {
@apply text-gray-500;
}
}
@utility fg-inverse {
@apply text-white;
@variant theme-dark {
@apply text-gray-900;
}
}
@utility fg-primary {
@apply text-gray-900;
@variant theme-dark {
@apply text-white;
}
}
@utility fg-primary-variant {
@apply text-gray-800;
@variant theme-dark {
@apply text-gray-50;
}
}
@utility fg-secondary {
@apply text-gray-50;
@variant theme-dark {
@apply text-gray-700;
}
}
@utility fg-secondary-variant {
@apply text-gray-100;
@variant theme-dark {
@apply text-gray-600;
}
}
@utility fg-subdued {
@apply text-gray-400;
@variant theme-dark {
@apply text-gray-500;
}
}
@layer base {
form>button {
@apply cursor-pointer;
button {
@apply cursor-pointer focus-visible:outline-gray-900;
}
hr {
@@ -331,22 +490,14 @@
details>summary {
@apply list-none;
}
select[multiple="multiple"] {
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
}
select[multiple="multiple"] option {
@apply py-2 rounded-md;
}
select[multiple="multiple"] option:checked {
@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;
input[type='radio'] {
@apply border-gray-300 text-indigo-600 focus:ring-indigo-600; /* Default light mode */
@variant theme-dark {
/* Dark mode radio button base and checked styles */
@apply border-gray-600 bg-gray-700 checked:bg-blue-500 focus:ring-blue-500 focus:ring-offset-gray-800;
}
}
}
@@ -354,44 +505,97 @@
/* Buttons */
.btn {
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500;
@apply transition-all duration-300;
}
.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;
@apply button-bg-primary text-white disabled:text-gray-400;
@apply hover:button-bg-primary-hover;
@apply disabled:button-bg-disabled disabled:hover:button-bg-disabled;
@variant theme-dark {
@apply button-bg-primary fg-primary;
@apply hover:button-bg-primary-hover;
@apply disabled:button-bg-disabled disabled:hover:button-bg-disabled;
}
}
.btn--secondary {
@apply bg-gray-50 hover:bg-gray-100 text-gray-900;
@apply button-bg-secondary text-primary;
@apply hover:button-bg-secondary-hover;
@variant theme-dark {
@apply button-bg-secondary text-white;
@apply hover:button-bg-secondary-hover;
}
}
.btn--outline {
@apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
@apply border border-alpha-black-200 text-primary disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-gray-400;
@apply hover:button-bg-outline-hover;
@variant theme-dark {
@apply border-alpha-white-300 text-white disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-gray-600;
@apply hover:button-bg-outline-hover;
}
}
.btn--ghost {
@apply border border-transparent text-gray-900 hover:bg-gray-100;
@apply border border-transparent text-primary hover:button-bg-ghost-hover;
@variant theme-dark {
@apply fg-primary hover:button-bg-ghost-hover;
}
}
.btn--destructive {
@apply bg-red-500 text-white hover:bg-red-600 disabled:bg-red-50 disabled:hover:bg-red-50 disabled:text-red-400;
@apply button-bg-destructive text-white hover:button-bg-destructive-hover disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-red-400;
@variant theme-dark {
@apply button-bg-destructive text-white hover:button-bg-destructive-hover disabled:button-bg-disabled disabled:hover:button-bg-disabled;
}
}
/* Forms */
.form-field {
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-white border-alpha-black-100 shadow-xs w-full;
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-container border-secondary shadow-xs w-full;
@apply focus-within:border-secondary focus-within:shadow-none focus-within:ring-4 focus-within:ring-alpha-black-200;
@apply transition-all duration-300;
@variant theme-dark {
@apply focus-within:ring-alpha-white-300;
}
/* Add styles for multiple select within form fields */
select[multiple] {
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
option {
@apply py-2 rounded-md;
}
option:checked {
@apply after:content-['\2713'] bg-container-inset after:text-gray-500 after:ml-2;
}
option:active,
option:focus {
@apply bg-container-inset;
}
}
}
.form-field__label {
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
@apply block text-xs text-secondary peer-disabled:text-subdued;
}
.form-field__input {
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
@apply focus:opacity-100 focus:outline-hidden focus:ring-0;
@apply placeholder-shown:opacity-50;
@apply disabled:text-gray-400;
@apply disabled:text-subdued;
@apply text-ellipsis overflow-hidden whitespace-nowrap;
@apply transition-opacity duration-300;
&select {
@apply pr-8;
@@ -399,17 +603,18 @@
}
.form-field__radio {
@apply text-gray-900;
@apply text-primary;
}
.form-field__submit {
@apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
@apply cursor-pointer rounded-lg bg-surface p-3 text-center text-white hover:bg-surface-hover;
}
/* Checkboxes */
.checkbox {
&[type='checkbox'] {
@apply rounded-sm;
@apply transition-colors duration-300;
}
}
@@ -440,12 +645,119 @@
/* Switches */
.switch {
@apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer;
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out;
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full;
@apply after:transition-transform after:duration-300 after:ease-in-out;
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
@apply transition-colors duration-300;
}
/* Tooltips */
.tooltip {
@apply hidden absolute;
}
}
@layer utilities {
/* Specific override for strong tags in prose under dark mode */
.prose:where([data-theme=dark], [data-theme=dark] *) strong {
color: theme(colors.white) !important;
}
}
/* Button Backgrounds */
@utility button-bg-primary {
@apply bg-gray-900; /* Maps to fg-primary light */
@variant theme-dark {
@apply bg-white; /* Maps to fg-primary dark */
}
}
@utility button-bg-primary-hover {
@apply bg-gray-800; /* Maps to fg-primary-variant light */
@variant theme-dark {
@apply bg-gray-50; /* Maps to fg-primary-variant dark */
}
}
@utility button-bg-secondary {
@apply bg-gray-50; /* Maps to fg-secondary light */
@variant theme-dark {
@apply bg-gray-700; /* Maps to fg-secondary dark */
}
}
@utility button-bg-secondary-hover {
@apply bg-gray-100; /* Maps to fg-secondary-variant light */
@variant theme-dark {
@apply bg-gray-600; /* Maps to fg-secondary-variant dark */
}
}
@utility button-bg-disabled {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-gray-700;
}
}
@utility button-bg-destructive {
@apply bg-red-500;
@variant theme-dark {
@apply bg-red-400;
}
}
@utility button-bg-destructive-hover {
@apply bg-red-600;
@variant theme-dark {
@apply bg-red-500;
}
}
@utility button-bg-ghost-hover {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-gray-800;
}
}
@utility button-bg-outline-hover {
@apply bg-gray-100;
@variant theme-dark {
@apply bg-gray-700;
}
}
/* Tab Styles */
@utility tab-item-active {
@apply bg-white;
@variant theme-dark {
@apply bg-gray-700;
}
}
@utility tab-item-hover {
@apply bg-gray-200;
@variant theme-dark {
@apply bg-gray-800;
}
}
@utility tab-bg-group {
@apply bg-gray-50;
@variant theme-dark {
@apply bg-alpha-black-700;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,10 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
rescue_from StandardError, with: :report_error
private
def report_error(e)
Sentry.capture_exception(e)
end
end
end

View File

@@ -9,9 +9,12 @@ class Account::HoldingsController < ApplicationController
end
def destroy
@holding.destroy_holding_and_entries!
flash[:notice] = t(".success")
if @holding.account.plaid_account_id.present?
flash[:alert] = "You cannot delete this holding"
else
@holding.destroy_holding_and_entries!
flash[:notice] = t(".success")
end
respond_to do |format|
format.html { redirect_back_or_to account_path(@holding.account) }

View File

@@ -10,7 +10,7 @@ class Account::TradesController < ApplicationController
def create_entry_params
params.require(:account_entry).permit(
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :type, :transfer_account_id
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id
).tap do |params|
account_id = params.delete(:account_id)
params[:account] = Current.family.accounts.find(account_id)

View File

@@ -3,7 +3,7 @@ class Account::TransferMatchesController < ApplicationController
def new
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
@transfer_match_candidates = @entry.transfer_match_candidates
@transfer_match_candidates = @entry.account_transaction.transfer_match_candidates
end
def create

View File

@@ -1,5 +1,6 @@
class AccountsController < ApplicationController
before_action :set_account, only: %i[sync chart sparkline]
include Periodable
def index
@manual_accounts = family.accounts.manual.alphabetically
@@ -17,6 +18,7 @@ class AccountsController < ApplicationController
end
def chart
@chart_view = params[:chart_view] || "balance"
render layout: "application"
end

View File

@@ -1,10 +1,11 @@
class ApplicationController < ActionController::Base
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable
include Pagy::Backend
helper_method :require_upgrade?, :subscription_pending?
before_action :detect_os
before_action :set_default_chat
private
def require_upgrade?
@@ -33,4 +34,10 @@ class ApplicationController < ActionController::Base
else ""
end
end
# By default, we show the user the last chat they interacted with
def set_default_chat
@last_viewed_chat = Current.user&.last_viewed_chat
@chat = @last_viewed_chat
end
end

View File

@@ -25,6 +25,7 @@ class BudgetsController < ApplicationController
end
private
def budget_create_params
params.require(:budget).permit(:start_date)
end

View File

@@ -0,0 +1,67 @@
class ChatsController < ApplicationController
include ActionView::RecordIdentifier
guard_feature unless: -> { Current.user.ai_enabled? }
before_action :set_chat, only: [ :show, :edit, :update, :destroy ]
def index
@chat = nil # override application_controller default behavior of setting @chat to last viewed chat
@chats = Current.user.chats.order(created_at: :desc)
end
def show
set_last_viewed_chat(@chat)
end
def new
@chat = Current.user.chats.new(title: "New chat #{Time.current.strftime("%Y-%m-%d %H:%M")}")
end
def create
@chat = Current.user.chats.start!(chat_params[:content], model: chat_params[:ai_model])
set_last_viewed_chat(@chat)
redirect_to chat_path(@chat, thinking: true)
end
def edit
end
def update
@chat.update!(chat_params)
respond_to do |format|
format.html { redirect_back_or_to chat_path(@chat), notice: "Chat updated" }
format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@chat, :title), partial: "chats/chat_title", locals: { chat: @chat }) }
end
end
def destroy
@chat.destroy
clear_last_viewed_chat
redirect_to chats_path, notice: "Chat was successfully deleted"
end
def retry
@chat.retry_last_message!
redirect_to chat_path(@chat, thinking: true)
end
private
def set_chat
@chat = Current.user.chats.find(params[:id])
end
def set_last_viewed_chat(chat)
Current.user.update!(last_viewed_chat: chat)
end
def clear_last_viewed_chat
Current.user.update!(last_viewed_chat: nil)
end
def chat_params
params.require(:chat).permit(:title, :content, :ai_model)
end
end

View File

@@ -2,7 +2,7 @@ module AccountableResource
extend ActiveSupport::Concern
included do
include ScrollFocusable
include ScrollFocusable, Periodable
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
before_action :set_link_token, only: :new
@@ -23,6 +23,7 @@ module AccountableResource
end
def show
@chart_view = params[:chart_view] || "balance"
@q = params.fetch(:q, {}).permit(:search)
entries = @account.entries.search(@q).reverse_chronological

View File

@@ -4,11 +4,13 @@ module Authentication
included do
before_action :set_request_details
before_action :authenticate_user!
before_action :set_sentry_user
end
class_methods do
def skip_authentication(**options)
skip_before_action :authenticate_user!, **options
skip_before_action :set_sentry_user, **options
end
end
@@ -26,7 +28,13 @@ module Authentication
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_token])
cookie_value = cookies.signed[:session_token]
if cookie_value.present?
Session.find_by(id: cookie_value)
else
nil
end
end
def create_session_for(user)
@@ -43,4 +51,17 @@ module Authentication
Current.user_agent = request.user_agent
Current.ip_address = request.ip
end
def set_sentry_user
return unless defined?(Sentry) && ENV["SENTRY_DSN"].present?
if Current.user
Sentry.set_user(
id: Current.user.id,
email: Current.user.email,
username: Current.user.display_name,
ip_address: Current.ip_address
)
end
end
end

View File

@@ -13,6 +13,7 @@ module AutoSync
def family_needs_auto_sync?
return false unless Current.family.present?
return false unless Current.family.accounts.active.any?
(Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current
end

View File

@@ -0,0 +1,13 @@
module Breadcrumbable
extend ActiveSupport::Concern
included do
before_action :set_breadcrumbs
end
private
# The default, unless specific controller or action explicitly overrides
def set_breadcrumbs
@breadcrumbs = [ [ "Home", root_path ], [ controller_name.titleize, nil ] ]
end
end

View File

@@ -0,0 +1,23 @@
# Simple feature guard that renders a 403 Forbidden status with a message
# when the feature is disabled.
#
# Example:
#
# class MessagesController < ApplicationController
# guard_feature unless: -> { Current.user.ai_enabled? }
# end
#
module FeatureGuardable
extend ActiveSupport::Concern
class_methods do
def guard_feature(**options)
before_action :guard_feature, **options
end
end
private
def guard_feature
render plain: "Feature disabled: #{controller_name}##{action_name}", status: :forbidden
end
end

View File

@@ -0,0 +1,14 @@
module Periodable
extend ActiveSupport::Concern
included do
before_action :set_period
end
private
def set_period
@period = Period.from_key(params[:period] || Current.user&.default_period)
rescue Period::InvalidKeyError
@period = Period.last_30_days
end
end

View File

@@ -35,6 +35,7 @@ class Import::ConfigurationsController < ApplicationController
:notes_col_label,
:currency_col_label,
:date_format,
:number_format,
:signage_convention
)
end

View File

@@ -4,6 +4,10 @@ class Import::ConfirmsController < ApplicationController
before_action :set_import
def show
if @import.mapping_steps.empty?
return redirect_to import_path(@import)
end
redirect_to import_clean_path(@import), alert: "You have invalid data, please edit until all errors are resolved" unless @import.cleaned?
end

View File

@@ -2,9 +2,7 @@ class Import::RowsController < ApplicationController
before_action :set_import_row
def update
@row.assign_attributes(row_params)
@row.save!(validate: false)
@row.sync_mappings
@row.update_and_sync(row_params)
redirect_to import_row_path(@row.import, @row)
end

View File

@@ -8,10 +8,11 @@ class Import::UploadsController < ApplicationController
def update
if csv_valid?(csv_str)
@import.account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))
@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."
redirect_to import_configuration_path(@import, template_hint: true), notice: "CSV uploaded successfully."
else
flash.now[:alert] = "Must be valid CSV with headers and at least one row of data"
@@ -29,10 +30,8 @@ class Import::UploadsController < ApplicationController
end
def csv_valid?(str)
require "csv"
begin
csv = CSV.parse(str || "", headers: true, col_sep: upload_params[:col_sep])
csv = Import.parse_csv_str(str, col_sep: upload_params[:col_sep])
return false if csv.headers.empty?
return false if csv.count == 0
true

View File

@@ -1,5 +1,5 @@
class ImportsController < ApplicationController
before_action :set_import, only: %i[show publish destroy revert]
before_action :set_import, only: %i[show publish destroy revert apply_template]
def publish
@import.publish_later
@@ -18,7 +18,12 @@ class ImportsController < ApplicationController
end
def create
import = Current.family.imports.create! import_params
account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))
import = Current.family.imports.create!(
type: import_params[:type],
account: account,
date_format: Current.family.date_format,
)
redirect_to import_upload_path(import)
end
@@ -36,6 +41,15 @@ class ImportsController < ApplicationController
redirect_to imports_path, notice: "Import is reverting in the background."
end
def apply_template
if @import.suggested_template
@import.apply_template!(@import.suggested_template)
redirect_to import_configuration_path(@import), notice: "Template applied."
else
redirect_to import_configuration_path(@import), alert: "No template found, please manually configure your import."
end
end
def destroy
@import.destroy

View File

@@ -1,20 +0,0 @@
class Issue::ExchangeRateProviderMissingsController < ApplicationController
before_action :set_issue, only: :update
def update
Setting.synth_api_key = exchange_rate_params[:synth_api_key]
account = @issue.issuable
account.sync_later
redirect_back_or_to account
end
private
def set_issue
@issue = Current.family.issues.find(params[:id])
end
def exchange_rate_params
params.require(:issue_exchange_rate_provider_missing).permit(:synth_api_key)
end
end

View File

@@ -1,13 +0,0 @@
class IssuesController < ApplicationController
before_action :set_issue, only: :show
def show
render template: "#{@issue.class.name.underscore.pluralize}/show", layout: "issues"
end
private
def set_issue
@issue = Current.family.issues.find(params[:id])
end
end

View File

@@ -0,0 +1,24 @@
class MessagesController < ApplicationController
guard_feature unless: -> { Current.user.ai_enabled? }
before_action :set_chat
def create
@message = UserMessage.create!(
chat: @chat,
content: message_params[:content],
ai_model: message_params[:ai_model]
)
redirect_to chat_path(@chat, thinking: true)
end
private
def set_chat
@chat = Current.user.chats.find(params[:chat_id])
end
def message_params
params.require(:message).permit(:content, :ai_model)
end
end

View File

@@ -20,7 +20,10 @@ class MfaController < ApplicationController
def verify
@user = User.find_by(id: session[:mfa_user_id])
redirect_to new_session_path unless @user
if @user.nil?
redirect_to new_session_path
end
end
def verify_code

View File

@@ -1,14 +1,16 @@
class PagesController < ApplicationController
skip_before_action :authenticate_user!, only: %i[early_access]
include Periodable
def dashboard
@period = Period.from_key(params[:period], fallback: true)
@balance_sheet = Current.family.balance_sheet
@accounts = Current.family.accounts.active.with_attached_logo
@breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ]
end
def changelog
@release_notes = Provider::Github.new.fetch_latest_release_notes
@release_notes = github_provider.fetch_latest_release_notes
render layout: "settings"
end
@@ -24,4 +26,9 @@ class PagesController < ApplicationController
@invite_code = InviteCode.order("RANDOM()").limit(1).first
render layout: false
end
private
def github_provider
Provider::Registry.get_provider(:github)
end
end

View File

@@ -22,7 +22,7 @@ class PlaidItemsController < ApplicationController
end
respond_to do |format|
format.html { redirect_to accounts_path }
format.html { redirect_back_or_to accounts_path }
format.json { head :ok }
end
end

View File

@@ -1,11 +1,8 @@
class SecuritiesController < ApplicationController
def index
query = params[:q]
return render json: [] if query.blank? || query.length < 2 || query.length > 100
@securities = Security.search({
search: query,
country: params[:country_code] == "US" ? "US" : nil
})
@securities = Security.search_provider(
params[:q],
country_code: params[:country_code] == "US" ? "US" : nil
)
end
end

View File

@@ -1,25 +1,16 @@
class Settings::HostingsController < ApplicationController
layout "settings"
before_action :raise_if_not_self_hosted
guard_feature unless: -> { self_hosted? }
before_action :ensure_admin, only: :clear_cache
def show
@synth_usage = Current.family.synth_usage
synth_provider = Provider::Registry.get_provider(:synth)
@synth_usage = synth_provider&.usage
end
def update
if hosting_params[:upgrades_setting].present?
mode = hosting_params[:upgrades_setting] == "manual" ? "manual" : "auto"
target = hosting_params[:upgrades_setting] == "commit" ? "commit" : "release"
Setting.upgrades_mode = mode
Setting.upgrades_target = target
end
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
@@ -38,12 +29,17 @@ class Settings::HostingsController < ApplicationController
render :show, status: :unprocessable_entity
end
def clear_cache
DataCacheClearJob.perform_later(Current.family)
redirect_to settings_hosting_path, notice: t(".cache_cleared")
end
private
def hosting_params
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key)
params.require(:setting).permit(:require_invite_for_signup, :require_email_confirmation, :synth_api_key)
end
def raise_if_not_self_hosted
raise "Settings not available on non-self-hosted instance" unless self_hosted?
def ensure_admin
redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin?
end
end

View File

@@ -1,4 +1,6 @@
class SubscriptionsController < ApplicationController
before_action :redirect_to_root_if_self_hosted
def new
if Current.family.stripe_customer_id.blank?
customer = stripe_client.v1.customers.create(
@@ -44,4 +46,8 @@ class SubscriptionsController < ApplicationController
def stripe_client
@stripe_client ||= Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
end
def redirect_to_root_if_self_hosted
redirect_to root_path, alert: I18n.t("subscriptions.self_hosted_alert") if self_hosted?
end
end

View File

@@ -49,6 +49,7 @@ class TransactionsController < ApplicationController
end
private
def search_params
cleaned_params = params.fetch(:q, {})
.permit(

View File

@@ -36,14 +36,11 @@ class TransfersController < ApplicationController
end
def update
if transfer_update_params[:status] == "rejected"
@transfer.reject!
elsif transfer_update_params[:status] == "confirmed"
@transfer.confirm!
Transfer.transaction do
update_transfer_status
update_transfer_details
end
@transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id])
respond_to do |format|
format.html { redirect_back_or_to transactions_url, notice: t(".success") }
format.turbo_stream
@@ -69,4 +66,17 @@ class TransfersController < ApplicationController
def transfer_update_params
params.require(:transfer).permit(:notes, :status, :category_id)
end
def update_transfer_status
if transfer_update_params[:status] == "rejected"
@transfer.reject!
elsif transfer_update_params[:status] == "confirmed"
@transfer.confirm!
end
end
def update_transfer_details
@transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id])
@transfer.update!(notes: transfer_update_params[:notes])
end
end

View File

@@ -1,56 +0,0 @@
class UpgradesController < ApplicationController
before_action :verify_upgrades_enabled
def acknowledge
commit_sha = params[:id]
upgrade = Upgrader.find_upgrade(commit_sha)
if upgrade
if upgrade.available?
Current.user.acknowledge_upgrade_prompt(upgrade.commit_sha)
flash[:notice] = t(".upgrade_dismissed")
elsif upgrade.complete?
Current.user.acknowledge_upgrade_alert(upgrade.commit_sha)
flash[:notice] = t(".upgrade_complete_dismiss")
else
flash[:alert] = t(".upgrade_not_available")
end
else
flash[:alert] = t(".upgrade_not_found")
end
redirect_back(fallback_location: root_path)
end
def deploy
commit_sha = params[:id]
upgrade = Upgrader.find_upgrade(commit_sha)
unless upgrade
flash[:alert] = t(".upgrade_not_found")
return redirect_back(fallback_location: root_path)
end
prior_acknowledged_upgrade_commit_sha = Current.user.last_prompted_upgrade_commit_sha
# Optimistically acknowledge the upgrade prompt
Current.user.acknowledge_upgrade_prompt(upgrade.commit_sha)
upgrade_result = Upgrader.upgrade_to(upgrade)
if upgrade_result[:success]
flash[:notice] = upgrade_result[:message]
else
# If the upgrade fails, revert to the prior acknowledged upgrade
Current.user.acknowledge_upgrade_prompt(prior_acknowledged_upgrade_commit_sha)
flash[:alert] = upgrade_result[:message]
end
redirect_back(fallback_location: root_path)
end
private
def verify_upgrades_enabled
head :not_found unless ENV["UPGRADES_ENABLED"] == "true"
end
end

View File

@@ -1,5 +1,6 @@
class UsersController < ApplicationController
before_action :set_user
before_action :ensure_admin, only: :reset
def update
@user = Current.user
@@ -16,16 +17,29 @@ class UsersController < ApplicationController
redirect_to settings_profile_path, alert: error_message
end
else
was_ai_enabled = @user.ai_enabled
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
@user.profile_image.purge if should_purge_profile_image?
# Add a special notice if AI was just enabled
notice = if !was_ai_enabled && @user.ai_enabled
"AI Assistant has been enabled successfully."
else
t(".success")
end
respond_to do |format|
format.html { handle_redirect(t(".success")) }
format.html { handle_redirect(notice) }
format.json { head :ok }
end
end
end
def reset
FamilyResetJob.perform_later(Current.family)
redirect_to settings_profile_path, notice: t(".success")
end
def destroy
if @user.deactivate
Current.session.destroy
@@ -60,7 +74,7 @@ class UsersController < ApplicationController
def user_params
params.require(:user).permit(
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar,
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, :show_ai_sidebar, :ai_enabled, :theme,
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ]
)
end
@@ -68,4 +82,8 @@ class UsersController < ApplicationController
def set_user
@user = Current.user
end
def ensure_admin
redirect_to settings_profile_path, alert: I18n.t("users.reset.unauthorized") unless Current.user.admin?
end
end

View File

@@ -1,6 +1,24 @@
module Account::EntriesHelper
def entries_by_date(entries, totals: false)
entries.group_by(&:date).map do |date, grouped_entries|
transfer_groups = entries.group_by do |entry|
# Only check for transfer if it's a transaction
next nil unless entry.entryable_type == "Account::Transaction"
entry.entryable.transfer&.id
end
# For a more intuitive UX, we do not want to show the same transfer twice in the list
deduped_entries = transfer_groups.flat_map do |transfer_id, grouped_entries|
if transfer_id.nil? || grouped_entries.size == 1
grouped_entries
else
grouped_entries.reject do |e|
e.entryable_type == "Account::Transaction" &&
e.entryable.transfer_as_inflow.present?
end
end
end
deduped_entries.group_by(&:date).sort.reverse_each.map do |date, grouped_entries|
content = capture do
yield grouped_entries
end

View File

@@ -129,6 +129,74 @@ module ApplicationHelper
cookies[:admin] == "true"
end
# Renders Markdown text using Redcarpet
def markdown(text)
return "" if text.blank?
renderer = Redcarpet::Render::HTML.new(
hard_wrap: true,
link_attributes: { target: "_blank", rel: "noopener noreferrer" }
)
markdown = Redcarpet::Markdown.new(
renderer,
autolink: true,
tables: true,
fenced_code_blocks: true,
strikethrough: true,
superscript: true,
underline: true,
highlight: true,
quote: true,
footnotes: true
)
markdown.render(text).html_safe
end
# Determines the starting widths of each panel depending on the user's sidebar preferences
def app_sidebar_config(user)
left_sidebar_showing = user.show_sidebar?
right_sidebar_showing = user.show_ai_sidebar?
content_max_width = if !left_sidebar_showing && !right_sidebar_showing
1024 # 5xl
elsif left_sidebar_showing && !right_sidebar_showing
896 # 4xl
else
768 # 3xl
end
left_panel_min_width = 320
left_panel_max_width = 320
right_panel_min_width = 400
right_panel_max_width = 550
left_panel_width = left_sidebar_showing ? left_panel_min_width : 0
right_panel_width = if right_sidebar_showing
left_sidebar_showing ? right_panel_min_width : right_panel_max_width
else
0
end
{
left_panel: {
is_open: left_sidebar_showing,
initial_width: left_panel_width,
min_width: left_panel_min_width,
max_width: left_panel_max_width
},
right_panel: {
is_open: right_sidebar_showing,
initial_width: right_panel_width,
min_width: right_panel_min_width,
max_width: right_panel_max_width,
overflow: right_sidebar_showing ? "auto" : "hidden"
},
content_max_width: content_max_width
}
end
private
def calculate_total(item, money_method, negate)
items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? }

View File

@@ -0,0 +1,12 @@
module ChatsHelper
def chat_frame
:sidebar_chat
end
def chat_view_path(chat)
return new_chat_path if params[:chat_view] == "new"
return chats_path if chat.nil? || params[:chat_view] == "all"
chat.persisted? ? chat_path(chat) : new_chat_path
end
end

View File

@@ -17,7 +17,7 @@ module FormsHelper
end
end
def period_select(form:, selected:, classes: "border border-tertiary shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0")
def period_select(form:, selected:, classes: "border border-secondary bg-container-inset rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0")
periods_for_select = Period.all.map { |period| [ period.label_short, period.key ] }
form.select(:period, periods_for_select, { selected: selected.key }, class: classes, data: { "auto-submit-form-target": "auto" })
@@ -30,7 +30,7 @@ end
private
def radio_tab_contents(label:, icon:)
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-subdued group-has-checked:bg-white group-has-checked:text-gray-800 group-has-checked:shadow-sm") do
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-subdued group-has-checked:bg-container group-has-checked:text-gray-800 group-has-checked:shadow-sm") do
concat lucide_icon(icon, class: "w-5 h-5")
concat tag.span(label, class: "group-has-checked:font-semibold")
end

View File

@@ -1,13 +1,20 @@
module MenusHelper
def contextual_menu(&block)
tag.div data: { controller: "menu" } do
concat contextual_menu_icon
def contextual_menu(icon: "more-horizontal", id: nil, &block)
tag.div id: id, data: { controller: "menu" } do
concat contextual_menu_icon(icon)
concat contextual_menu_content(&block)
end
end
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal)
link_to url, class: "flex items-center rounded-lg text-primary hover:bg-gray-50 py-2 px-3 gap-2", data: { turbo_frame: } do
link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary"))
concat(tag.span(label, class: "text-sm"))
end
end
def contextual_menu_item(label, url:, icon:, turbo_frame: nil)
link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary"))
concat(tag.span(label, class: "text-sm"))
end
@@ -16,7 +23,7 @@ module MenusHelper
def contextual_menu_destructive_item(label, url, turbo_confirm: true, turbo_frame: nil)
button_to url,
method: :delete,
class: "flex items-center w-full rounded-lg text-red-500 hover:bg-red-500/5 py-2 px-3 gap-2",
class: "flex items-center w-full rounded-md text-red-500 hover:bg-red-500/5 p-2 gap-2",
data: { turbo_confirm: turbo_confirm, turbo_frame: } do
concat(lucide_icon("trash-2", class: "shrink-0 w-5 h-5"))
concat(tag.span(label, class: "text-sm"))
@@ -24,14 +31,14 @@ module MenusHelper
end
private
def contextual_menu_icon
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-secondary"
def contextual_menu_icon(icon)
tag.button class: "w-9 h-9 flex justify-center items-center hover:bg-surface-hover rounded-lg cursor-pointer focus:outline-none focus-visible:outline-none", data: { menu_target: "button" } do
lucide_icon icon, class: "w-5 h-5 text-secondary"
end
end
def contextual_menu_content(&block)
tag.div class: "z-50 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden",
tag.div class: "min-w-[200px] p-1 z-50 shadow-border-xs bg-container rounded-lg hidden",
data: { menu_target: "content" } do
capture(&block)
end

View File

@@ -4,7 +4,7 @@ module SettingsHelper
{ name: I18n.t("settings.settings_nav.preferences_label"), path: :settings_preferences_path },
{ name: I18n.t("settings.settings_nav.security_label"), path: :settings_security_path },
{ name: I18n.t("settings.settings_nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
{ name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path },
{ name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path, condition: :not_self_hosted? },
{ name: I18n.t("settings.settings_nav.accounts_label"), path: :accounts_path },
{ name: I18n.t("settings.settings_nav.imports_label"), path: :imports_path },
{ name: I18n.t("settings.settings_nav.tags_label"), path: :tags_path },
@@ -45,4 +45,9 @@ module SettingsHelper
concat(next_setting)
end
end
private
def not_self_hosted?
!self_hosted?
end
end

View File

@@ -1,13 +0,0 @@
module UpgradesHelper
def get_upgrade_for_notification(user, upgrades_mode)
return nil unless ENV["UPGRADES_ENABLED"] == "true"
completed_upgrade = Upgrader.completed_upgrade
return completed_upgrade if completed_upgrade && user.last_alerted_upgrade_commit_sha != completed_upgrade.commit_sha
available_upgrade = Upgrader.available_upgrade
if available_upgrade && upgrades_mode == "manual" && user.last_prompted_upgrade_commit_sha != available_upgrade.commit_sha
available_upgrade
end
end
end

View File

@@ -0,0 +1,206 @@
import { Controller } from "@hotwired/stimulus"
import Pickr from '@simonwep/pickr'
export default class extends Controller {
static targets = ["pickerBtn", "colorInput", "colorsSection", "paletteSection", "pickerSection", "colorPreview", "avatar", "details", "icon","validationMessage","selection","colorPickerRadioBtn"];
static values = {
presetColors: Array,
};
initialize() {
this.pickerBtnTarget.addEventListener('click', () => {
this.showPaletteSection();
});
this.colorInputTarget.addEventListener('input', (e) => {
this.picker.setColor(e.target.value);
});
this.detailsTarget.addEventListener('toggle', (e) => {
if (!this.colorInputTarget.checkValidity()) {
e.preventDefault();
this.colorInputTarget.reportValidity();
e.target.open = true;
}
});
this.selectedIcon = null;
if (!this.presetColorsValue.includes(this.colorInputTarget.value)) {
this.colorPickerRadioBtnTarget.checked = true;
}
}
initPicker() {
const pickerContainer = document.createElement("div");
pickerContainer.classList.add("pickerContainer");
this.pickerSectionTarget.append(pickerContainer);
this.picker = Pickr.create({
el: this.pickerBtnTarget,
theme: 'monolith',
container: ".pickerContainer",
useAsButton: true,
showAlways: true,
default: this.colorInputTarget.value,
components: {
hue: true,
},
});
this.picker.on('change', (color) => {
const hexColor = color.toHEXA().toString();
const rgbacolor = color.toRGBA();
this.updateAvatarColors(hexColor);
this.updateSelectedIconColor(hexColor);
const backgroundColor = this.backgroundColor(rgbacolor, 10);
const contrastRatio = this.contrast(rgbacolor, backgroundColor);
this.colorInputTarget.value = hexColor;
this.colorInputTarget.dataset.colorPickerColorValue = hexColor;
this.colorPreviewTarget.style.backgroundColor = hexColor;
this.handleContrastValidation(contrastRatio);
});
}
updateAvatarColors(color) {
this.avatarTarget.style.backgroundColor = `${this.#backgroundColor(color)}`;
this.avatarTarget.style.color = color;
}
handleIconColorChange(e) {
const selectedIcon = e.target;
this.selectedIcon = selectedIcon;
const currentColor = this.colorInputTarget.value;
this.iconTargets.forEach(icon => {
const iconWrapper = icon.nextElementSibling;
iconWrapper.style.removeProperty("background-color")
iconWrapper.style.color = "black";
});
this.updateSelectedIconColor(currentColor);
}
handleIconChange(e) {
const iconSVG = e.currentTarget.closest('label').querySelector('svg').cloneNode(true);
this.avatarTarget.innerHTML = '';
iconSVG.style.padding = "0px"
iconSVG.classList.add("w-8","h-8")
this.avatarTarget.appendChild(iconSVG);
}
updateSelectedIconColor(color) {
if (this.selectedIcon) {
const iconWrapper = this.selectedIcon.nextElementSibling;
iconWrapper.style.backgroundColor = `${this.#backgroundColor(color)}`;
iconWrapper.style.color = color;
}
}
handleColorChange(e) {
const color = e.currentTarget.value;
this.colorInputTarget.value = color;
this.colorPreviewTarget.style.backgroundColor = color;
this.updateAvatarColors(color);
this.updateSelectedIconColor(color);
}
handleContrastValidation(contrastRatio) {
if (contrastRatio < 4.5) {
this.colorInputTarget.setCustomValidity("Poor contrast, choose darker color or auto-adjust.");
this.validationMessageTarget.classList.remove("hidden");
} else {
this.colorInputTarget.setCustomValidity("");
this.validationMessageTarget.classList.add("hidden");
}
}
autoAdjust(e){
const currentRGBA = this.picker.getColor();
const adjustedRGBA = this.darkenColor(currentRGBA).toString();
this.picker.setColor(adjustedRGBA);
}
handleParentChange(e) {
const parent = e.currentTarget.value;
const display = typeof parent === "string" && parent !== "" ? "none" : "flex";
this.selectionTarget.style.display = display;
}
backgroundColor([r,g,b,a], percentage) {
const mixedR = Math.round((r * (percentage / 100)) + (255 * (1 - percentage / 100)));
const mixedG = Math.round((g * (percentage / 100)) + (255 * (1 - percentage / 100)));
const mixedB = Math.round((b * (percentage / 100)) + (255 * (1 - percentage / 100)));
return [mixedR, mixedG, mixedB];
}
luminance([r,g,b]) {
const toLinear = c => {
const scaled = c / 255;
return scaled <= 0.04045
? scaled / 12.92
: ((scaled + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
contrast(foregroundColor, backgroundColor) {
const fgLum = this.luminance(foregroundColor);
const bgLum = this.luminance(backgroundColor);
const [l1, l2] = [Math.max(fgLum, bgLum), Math.min(fgLum, bgLum)];
return (l1 + 0.05) / (l2 + 0.05);
}
darkenColor(color) {
let darkened = color.toRGBA();
const backgroundColor = this.backgroundColor(darkened, 10);
let contrastRatio = this.contrast(darkened, backgroundColor);
while (contrastRatio < 4.5 && (darkened[0] > 0 || darkened[1] > 0 || darkened[2] > 0)) {
darkened = [
Math.max(0, darkened[0] - 10),
Math.max(0, darkened[1] - 10),
Math.max(0, darkened[2] - 10),
darkened[3]
];
contrastRatio = this.contrast(darkened, backgroundColor);
}
return `rgba(${darkened.join(", ")})`;
}
showPaletteSection() {
this.initPicker();
this.colorsSectionTarget.classList.add('hidden');
this.paletteSectionTarget.classList.remove('hidden');
this.pickerSectionTarget.classList.remove('hidden');
this.picker.show();
}
showColorsSection() {
this.colorsSectionTarget.classList.remove('hidden');
this.paletteSectionTarget.classList.add('hidden');
this.pickerSectionTarget.classList.add('hidden');
if (this.picker) {
this.picker.destroyAndRemove();
}
}
toggleSections() {
if (this.colorsSectionTarget.classList.contains('hidden')) {
this.showColorsSection();
} else {
this.showPaletteSection();
}
}
#backgroundColor(color) {
return `color-mix(in oklab, ${color} 10%, transparent)`;
}
}

View File

@@ -0,0 +1,60 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["messages", "form", "input"];
connect() {
this.#configureAutoScroll();
}
disconnect() {
if (this.messagesObserver) {
this.messagesObserver.disconnect();
}
}
autoResize() {
const input = this.inputTarget;
const lineHeight = 20; // text-sm line-height (14px * 1.429 ≈ 20px)
const maxLines = 3; // 3 lines = 60px total
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, lineHeight * maxLines)}px`;
input.style.overflowY =
input.scrollHeight > lineHeight * maxLines ? "auto" : "hidden";
}
submitSampleQuestion(e) {
this.inputTarget.value = e.target.dataset.chatQuestionParam;
setTimeout(() => {
this.formTarget.requestSubmit();
}, 200);
}
// Newlines require shift+enter, otherwise submit the form (same functionality as ChatGPT and others)
handleInputKeyDown(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this.formTarget.requestSubmit();
}
}
#configureAutoScroll() {
this.messagesObserver = new MutationObserver((_mutations) => {
if (this.hasMessagesTarget) {
this.#scrollToBottom();
}
});
// Listen to entire sidebar for changes, always try to scroll to the bottom
this.messagesObserver.observe(this.element, {
childList: true,
subtree: true,
});
}
#scrollToBottom = () => {
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight;
};
}

View File

@@ -21,14 +21,8 @@ export default class extends Controller {
handleColorChange(e) {
const color = e.currentTarget.value;
this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 5%, white)`;
this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 10%, white)`;
this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`;
this.avatarTarget.style.color = color;
}
handleParentChange(e) {
const parent = e.currentTarget.value;
const visibility = typeof parent === "string" && parent !== "" ? "hidden" : "visible"
this.selectionTarget.style.visibility = visibility
}
}

View File

@@ -4,6 +4,10 @@ import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["input", "list", "emptyMessage"];
connect() {
this.inputTarget.focus();
}
filter() {
const filterValue = this.inputTarget.value.toLowerCase();
const items = this.listTarget.querySelectorAll(".filterable-item");

View File

@@ -2,17 +2,75 @@ import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="sidebar"
export default class extends Controller {
static values = { userId: String };
static targets = ["panel", "content"];
static values = {
userId: String,
config: Object,
};
toggle() {
this.panelTarget.classList.toggle("w-0");
this.panelTarget.classList.toggle("opacity-0");
this.panelTarget.classList.toggle("w-[260px]");
this.panelTarget.classList.toggle("opacity-100");
this.contentTarget.classList.toggle("max-w-4xl");
this.contentTarget.classList.toggle("max-w-5xl");
static targets = ["leftPanel", "leftPanelMobile", "rightPanel", "content"];
initialize() {
this.leftPanelOpen = this.configValue.left_panel.is_open;
this.rightPanelOpen = this.configValue.right_panel.is_open;
}
toggleLeftPanel() {
this.leftPanelOpen = !this.leftPanelOpen;
this.#updatePanelWidths();
this.#persistPreference("show_sidebar", this.leftPanelOpen);
}
toggleLeftPanelMobile() {
if (this.leftPanelOpen) {
this.leftPanelMobileTarget.classList.remove("hidden");
this.leftPanelOpen = false;
} else {
this.leftPanelMobileTarget.classList.add("hidden");
this.leftPanelOpen = true;
}
}
toggleRightPanel() {
this.rightPanelOpen = !this.rightPanelOpen;
this.#updatePanelWidths();
this.#persistPreference("show_ai_sidebar", this.rightPanelOpen);
}
#updatePanelWidths() {
this.leftPanelTarget.style.width = `${this.#leftPanelWidth()}px`;
this.rightPanelTarget.style.width = `${this.#rightPanelWidth()}px`;
this.rightPanelTarget.style.overflow = this.#rightPanelOverflow();
}
#leftPanelWidth() {
if (this.leftPanelOpen) {
return this.configValue.left_panel.min_width;
}
return 0;
}
#rightPanelWidth() {
if (this.rightPanelOpen) {
if (this.leftPanelOpen) {
return this.configValue.right_panel.min_width;
}
return this.configValue.right_panel.max_width;
}
return 0;
}
#rightPanelOverflow() {
if (this.rightPanelOpen) {
return "auto";
}
return "hidden";
}
#persistPreference(field, value) {
fetch(`/users/${this.userIdValue}`, {
method: "PATCH",
headers: {
@@ -21,7 +79,7 @@ export default class extends Controller {
Accept: "application/json",
},
body: new URLSearchParams({
"user[show_sidebar]": !this.panelTarget.classList.contains("w-0"),
[`user[${field}]`]: value,
}).toString(),
});
}

View File

@@ -0,0 +1,73 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { userPreference: String }
connect() {
this.applyTheme()
this.startSystemThemeListener()
}
disconnect() {
this.stopSystemThemeListener()
}
// Called automatically by Stimulus when the userPreferenceValue changes (e.g., after form submit/page reload)
userPreferenceValueChanged() {
this.applyTheme()
}
// Called when a theme radio button is clicked
updateTheme(event) {
const selectedTheme = event.currentTarget.value
if (selectedTheme === "system") {
this.setTheme(this.systemPrefersDark())
} else if (selectedTheme === "dark") {
this.setTheme(true)
} else {
this.setTheme(false)
}
}
// Applies theme based on the userPreferenceValue (from server)
applyTheme() {
if (this.userPreferenceValue === "system") {
this.setTheme(this.systemPrefersDark())
} else if (this.userPreferenceValue === "dark") {
this.setTheme(true)
} else {
this.setTheme(false)
}
}
// Sets or removes the data-theme attribute
setTheme(isDark) {
if (isDark) {
document.documentElement.setAttribute("data-theme", "dark")
} else {
document.documentElement.removeAttribute("data-theme")
}
}
systemPrefersDark() {
return window.matchMedia("(prefers-color-scheme: dark)").matches
}
handleSystemThemeChange = (event) => {
// Only apply system theme changes if the user preference is currently 'system'
if (this.userPreferenceValue === "system") {
this.setTheme(event.matches)
}
}
startSystemThemeListener() {
this.darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
this.darkMediaQuery.addEventListener("change", this.handleSystemThemeChange)
}
stopSystemThemeListener() {
if (this.darkMediaQuery) {
this.darkMediaQuery.removeEventListener("change", this.handleSystemThemeChange)
}
}
}

View File

@@ -446,7 +446,7 @@ export default class extends Controller {
get _margin() {
if (this.useLabelsValue) {
return { top: 20, right: 0, bottom: 30, left: 0 };
return { top: 20, right: 0, bottom: 10, left: 0 };
}
return { top: 0, right: 0, bottom: 0, left: 0 };
}

View File

@@ -1,7 +1,5 @@
class ApplicationJob < ActiveJob::Base
# Automatically retry jobs that encountered a deadlock
# retry_on ActiveRecord::Deadlocked
# Most jobs are safe to ignore if the underlying records are no longer available
# discard_on ActiveJob::DeserializationError
retry_on ActiveRecord::Deadlocked
discard_on ActiveJob::DeserializationError
queue_as :low_priority # default queue
end

View File

@@ -0,0 +1,7 @@
class AssistantResponseJob < ApplicationJob
queue_as :high_priority
def perform(message)
message.request_response
end
end

View File

@@ -1,31 +0,0 @@
class AutoUpgradeJob < ApplicationJob
queue_as :latency_low
def perform(*args)
raise_if_disabled
return Rails.logger.info "Skipping auto-upgrades because app is set to manual upgrades. Please set UPGRADES_MODE=auto to enable auto-upgrades" if Setting.upgrades_mode == "manual"
Rails.logger.info "Searching for available auto-upgrades..."
candidate = Upgrader.available_upgrade_by_type(Setting.upgrades_target)
if candidate
if Rails.cache.read("last_auto_upgrade_commit_sha") == candidate.commit_sha
Rails.logger.info "Skipping auto upgrade: #{candidate.type} #{candidate.commit_sha} deploy in progress"
return
end
Rails.logger.info "Auto upgrading to #{candidate.type} #{candidate.commit_sha}..."
Upgrader.upgrade_to(candidate)
Rails.cache.write("last_auto_upgrade_commit_sha", candidate.commit_sha, expires_in: 1.day)
else
Rails.logger.info "No auto upgrade available at this time"
end
end
private
def raise_if_disabled
raise "Upgrades module is disabled. Please set UPGRADES_ENABLED=true to enable upgrade features" unless ENV["UPGRADES_ENABLED"] == "true"
end
end

View File

@@ -0,0 +1,16 @@
class DataCacheClearJob < ApplicationJob
queue_as :low_priority
def perform(family)
ActiveRecord::Base.transaction do
ExchangeRate.delete_all
Security::Price.delete_all
family.accounts.each do |account|
account.balances.delete_all
account.holdings.delete_all
end
family.sync_later
end
end
end

View File

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

View File

@@ -1,8 +1,7 @@
class EnrichTransactionBatchJob < ApplicationJob
queue_as :latency_high
queue_as :low_priority
def perform(account, batch_size = 100, offset = 0)
enricher = Account::DataEnricher.new(account)
enricher.enrich_transaction_batch(batch_size, offset)
account.enrich_transaction_batch(batch_size, offset)
end
end

View File

@@ -0,0 +1,19 @@
class FamilyResetJob < ApplicationJob
queue_as :low_priority
def perform(family)
# Delete all family data except users
ActiveRecord::Base.transaction do
# Delete accounts and related data
family.accounts.destroy_all
family.categories.destroy_all
family.tags.destroy_all
family.merchants.destroy_all
family.plaid_items.destroy_all
family.imports.destroy_all
family.budgets.destroy_all
family.sync_later
end
end
end

View File

@@ -1,8 +1,8 @@
class FetchSecurityInfoJob < ApplicationJob
queue_as :latency_low
queue_as :low_priority
def perform(security_id)
return unless Security.security_info_provider.present?
return unless Security.provider.present?
security = Security.find(security_id)
@@ -12,7 +12,7 @@ class FetchSecurityInfoJob < ApplicationJob
params[:mic_code] = security.exchange_mic if security.exchange_mic.present?
params[:operating_mic] = security.exchange_operating_mic if security.exchange_operating_mic.present?
security_info_response = Security.security_info_provider.fetch_security_info(**params)
security_info_response = Security.provider.fetch_security_info(**params)
security.update(
name: security_info_response.info.dig("name")

View File

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

View File

@@ -1,5 +1,5 @@
class RevertImportJob < ApplicationJob
queue_as :latency_low
queue_as :medium_priority
def perform(import)
import.revert

View File

@@ -1,7 +1,8 @@
class SyncJob < ApplicationJob
queue_as :latency_medium
queue_as :high_priority
def perform(sync)
sleep 1 # simulate work for faster jobs
sync.perform
end
end

View File

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

View File

@@ -1,11 +1,10 @@
class Account < ApplicationRecord
include Syncable, Monetizable, Issuable, Chartable
include Syncable, Monetizable, Chartable, Enrichable, Linkable, Convertible
validates :name, :balance, :currency, presence: true
belongs_to :family
belongs_to :import, optional: true
belongs_to :plaid_account, optional: true
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
@@ -14,7 +13,6 @@ class Account < ApplicationRecord
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
has_many :holdings, dependent: :destroy, class_name: "Account::Holding"
has_many :balances, dependent: :destroy
has_many :issues, as: :issuable, dependent: :destroy
monetize :balance, :cash_balance
@@ -75,12 +73,20 @@ class Account < ApplicationRecord
def sync_data(start_date: nil)
update!(last_synced_at: Time.current)
Syncer.new(self, start_date: start_date).run
Rails.logger.info("Auto-matching transfers")
family.auto_match_transfers!
Rails.logger.info("Processing balances (#{linked? ? 'reverse' : 'forward'})")
sync_balances
if enrichable?
Rails.logger.info("Enriching transaction data")
enrich_data
end
end
def post_sync
broadcast_remove_to(family, target: "syncing-notice")
resolve_stale_issues
accountable.post_sync
end
@@ -93,10 +99,6 @@ class Account < ApplicationRecord
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
end
def enrich_data
DataEnricher.new(self).run
end
def update_with_sync!(attributes)
should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance
@@ -123,11 +125,14 @@ class Account < ApplicationRecord
end
end
def sparkline_series
cache_key = family.build_cache_key("#{id}_sparkline")
Rails.cache.fetch(cache_key) do
balance_series
end
def start_date
first_entry_date = entries.minimum(:date) || Date.current
first_entry_date - 1.day
end
private
def sync_balances
strategy = linked? ? :reverse : :forward
Balance::Syncer.new(self, strategy: strategy).sync_balances
end
end

View File

@@ -0,0 +1,35 @@
class Account::Balance::BaseCalculator
attr_reader :account
def initialize(account)
@account = account
end
def calculate
Rails.logger.tagged(self.class.name) do
calculate_balances
end
end
private
def sync_cache
@sync_cache ||= Account::Balance::SyncCache.new(account)
end
def build_balance(date, cash_balance, holdings_value)
Account::Balance.new(
account_id: account.id,
date: date,
balance: holdings_value + cash_balance,
cash_balance: cash_balance,
currency: account.currency
)
end
def calculate_next_balance(prior_balance, transactions, direction: :forward)
flows = transactions.sum(&:amount)
negated = direction == :forward ? account.asset? : account.liability?
flows *= -1 if negated
prior_balance + flows
end
end

View File

@@ -0,0 +1,28 @@
class Account::Balance::ForwardCalculator < Account::Balance::BaseCalculator
private
def calculate_balances
current_cash_balance = 0
next_cash_balance = nil
@balances = []
account.start_date.upto(Date.current).each do |date|
entries = sync_cache.get_entries(date)
holdings = sync_cache.get_holdings(date)
holdings_value = holdings.sum(&:amount)
valuation = sync_cache.get_valuation(date)
next_cash_balance = if valuation
valuation.amount - holdings_value
else
calculate_next_balance(current_cash_balance, entries, direction: :forward)
end
@balances << build_balance(date, next_cash_balance, holdings_value)
current_cash_balance = next_cash_balance
end
@balances
end
end

View File

@@ -0,0 +1,32 @@
class Account::Balance::ReverseCalculator < Account::Balance::BaseCalculator
private
def calculate_balances
current_cash_balance = account.cash_balance
previous_cash_balance = nil
@balances = []
Date.current.downto(account.start_date).map do |date|
entries = sync_cache.get_entries(date)
holdings = sync_cache.get_holdings(date)
holdings_value = holdings.sum(&:amount)
valuation = sync_cache.get_valuation(date)
previous_cash_balance = if valuation
valuation.amount - holdings_value
else
calculate_next_balance(current_cash_balance, entries, direction: :reverse)
end
if valuation.present?
@balances << build_balance(date, previous_cash_balance, holdings_value)
else
@balances << build_balance(date, current_cash_balance, holdings_value)
end
current_cash_balance = previous_cash_balance
end
@balances
end
end

View File

@@ -0,0 +1,46 @@
class Account::Balance::SyncCache
def initialize(account)
@account = account
end
def get_valuation(date)
converted_entries.find { |e| e.date == date && e.account_valuation? }
end
def get_holdings(date)
converted_holdings.select { |h| h.date == date }
end
def get_entries(date)
converted_entries.select { |e| e.date == date && (e.account_transaction? || e.account_trade?) }
end
private
attr_reader :account
def converted_entries
@converted_entries ||= account.entries.order(:date).to_a.map do |e|
converted_entry = e.dup
converted_entry.amount = converted_entry.amount_money.exchange_to(
account.currency,
date: e.date,
fallback_rate: 1
).amount
converted_entry.currency = account.currency
converted_entry
end
end
def converted_holdings
@converted_holdings ||= account.holdings.map do |h|
converted_holding = h.dup
converted_holding.amount = converted_holding.amount_money.exchange_to(
account.currency,
date: h.date,
fallback_rate: 1
).amount
converted_holding.currency = account.currency
converted_holding
end
end
end

View File

@@ -0,0 +1,71 @@
class Account::Balance::Syncer
attr_reader :account, :strategy
def initialize(account, strategy:)
@account = account
@strategy = strategy
end
def sync_balances
Account::Balance.transaction do
sync_holdings
calculate_balances
Rails.logger.info("Persisting #{@balances.size} balances")
persist_balances
purge_stale_balances
if strategy == :forward
update_account_info
end
account.sync_required_exchange_rates
end
end
private
def sync_holdings
@holdings = Account::Holding::Syncer.new(account, strategy: strategy).sync_holdings
end
def update_account_info
calculated_balance = @balances.sort_by(&:date).last&.balance || 0
calculated_holdings_value = @holdings.select { |h| h.date == Date.current }.sum(&:amount) || 0
calculated_cash_balance = calculated_balance - calculated_holdings_value
Rails.logger.info("Balance update: cash=#{calculated_cash_balance}, total=#{calculated_balance}")
account.update!(
balance: calculated_balance,
cash_balance: calculated_cash_balance
)
end
def calculate_balances
@balances = calculator.calculate
end
def persist_balances
current_time = Time.now
account.balances.upsert_all(
@balances.map { |b| b.attributes
.slice("date", "balance", "cash_balance", "currency")
.merge("updated_at" => current_time) },
unique_by: %i[account_id date currency]
)
end
def purge_stale_balances
deleted_count = account.balances.delete_by("date < ?", account.start_date)
Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0
end
def calculator
if strategy == :reverse
Account::Balance::ReverseCalculator.new(account)
else
Account::Balance::ForwardCalculator.new(account)
end
end
end

View File

@@ -1,121 +0,0 @@
class Account::BalanceCalculator
def initialize(account, holdings: nil)
@account = account
@holdings = holdings || []
end
def calculate(reverse: false, start_date: nil)
cash_balances = reverse ? reverse_cash_balances : forward_cash_balances
cash_balances.map do |balance|
holdings_value = converted_holdings.select { |h| h.date == balance.date }.sum(&:amount)
balance.balance = balance.balance + holdings_value
balance
end.compact
end
private
attr_reader :account, :holdings
def oldest_date
converted_entries.first ? converted_entries.first.date - 1.day : Date.current
end
def reverse_cash_balances
prior_balance = account.cash_balance
Date.current.downto(oldest_date).map do |date|
entries_for_date = converted_entries.select { |e| e.date == date }
holdings_for_date = converted_holdings.select { |h| h.date == date }
valuation = entries_for_date.find { |e| e.account_valuation? }
current_balance = if valuation
# To get this to a cash valuation, we back out holdings value on day
valuation.amount - holdings_for_date.sum(&:amount)
else
transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? }
calculate_balance(prior_balance, transactions)
end
balance_record = Account::Balance.new(
account: account,
date: date,
balance: valuation ? current_balance : prior_balance,
cash_balance: valuation ? current_balance : prior_balance,
currency: account.currency
)
prior_balance = current_balance
balance_record
end
end
def forward_cash_balances
prior_balance = 0
current_balance = nil
oldest_date.upto(Date.current).map do |date|
entries_for_date = converted_entries.select { |e| e.date == date }
holdings_for_date = converted_holdings.select { |h| h.date == date }
valuation = entries_for_date.find { |e| e.account_valuation? }
current_balance = if valuation
# To get this to a cash valuation, we back out holdings value on day
valuation.amount - holdings_for_date.sum(&:amount)
else
transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? }
calculate_balance(prior_balance, transactions, inverse: true)
end
balance_record = Account::Balance.new(
account: account,
date: date,
balance: current_balance,
cash_balance: current_balance,
currency: account.currency
)
prior_balance = current_balance
balance_record
end
end
def converted_entries
@converted_entries ||= @account.entries.order(:date).to_a.map do |e|
converted_entry = e.dup
converted_entry.amount = converted_entry.amount_money.exchange_to(
account.currency,
date: e.date,
fallback_rate: 1
).amount
converted_entry.currency = account.currency
converted_entry
end
end
def converted_holdings
@converted_holdings ||= holdings.map do |h|
converted_holding = h.dup
converted_holding.amount = converted_holding.amount_money.exchange_to(
account.currency,
date: h.date,
fallback_rate: 1
).amount
converted_holding.currency = account.currency
converted_holding
end
end
def calculate_balance(prior_balance, transactions, inverse: false)
flows = transactions.sum(&:amount)
negated = inverse ? account.asset? : account.liability?
flows *= -1 if negated
prior_balance + flows
end
end

View File

@@ -2,26 +2,31 @@ module Account::Chartable
extend ActiveSupport::Concern
class_methods do
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up")
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance, interval: nil)
raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym)
series_interval = interval || period.interval
balances = Account::Balance.find_by_sql([
balance_series_query,
{
start_date: period.start_date,
end_date: period.end_date,
interval: period.interval,
interval: series_interval,
target_currency: currency
}
])
balances = gapfill_balances(balances)
balances = invert_balances(balances) if favorable_direction == "down"
values = [ nil, *balances ].each_cons(2).map do |prev, curr|
Series::Value.new(
date: curr.date,
date_formatted: I18n.l(curr.date, format: :long),
trend: Trend.new(
current: Money.new(curr.balance, currency),
previous: prev.nil? ? nil : Money.new(prev.balance, currency),
current: Money.new(balance_value_for(curr, view), currency),
previous: prev.nil? ? nil : Money.new(balance_value_for(prev, view), currency),
favorable_direction: favorable_direction
)
)
@@ -30,10 +35,10 @@ module Account::Chartable
Series.new(
start_date: period.start_date,
end_date: period.end_date,
interval: period.interval,
interval: series_interval,
trend: Trend.new(
current: Money.new(balances.last&.balance || 0, currency),
previous: Money.new(balances.first&.balance || 0, currency),
current: Money.new(balance_value_for(balances.last, view) || 0, currency),
previous: Money.new(balance_value_for(balances.first, view) || 0, currency),
favorable_direction: favorable_direction
),
values: values
@@ -51,6 +56,8 @@ module Account::Chartable
SELECT
d.date,
SUM(CASE WHEN accounts.classification = 'asset' THEN ab.balance ELSE -ab.balance END * COALESCE(er.rate, 1)) as balance,
SUM(CASE WHEN accounts.classification = 'asset' THEN ab.cash_balance ELSE -ab.cash_balance END * COALESCE(er.rate, 1)) as cash_balance,
SUM(CASE WHEN accounts.classification = 'asset' THEN ab.balance - ab.cash_balance ELSE 0 END * COALESCE(er.rate, 1)) as holdings_balance,
COUNT(CASE WHEN accounts.currency <> :target_currency AND er.rate IS NULL THEN 1 END) as missing_rates
FROM dates d
LEFT JOIN accounts ON accounts.id IN (#{all.select(:id).to_sql})
@@ -69,19 +76,46 @@ module Account::Chartable
SQL
end
def balance_value_for(balance_record, view)
return 0 if balance_record.nil?
case view.to_sym
when :balance then balance_record.balance
when :cash_balance then balance_record.cash_balance
when :holdings_balance then balance_record.holdings_balance
else
raise ArgumentError, "Invalid view type: #{view}"
end
end
def invert_balances(balances)
balances.map do |balance|
balance.balance = -balance.balance
balance.cash_balance = -balance.cash_balance
balance.holdings_balance = -balance.holdings_balance
balance
end
end
def gapfill_balances(balances)
gapfilled = []
prev = nil
prev_balance = nil
[ nil, *balances ].each_cons(2).each_with_index do |(prev, curr), index|
if index == 0 && curr.balance.nil?
curr.balance = 0 # Ensure all series start with a non-nil balance
elsif curr.balance.nil?
curr.balance = prev.balance
balances.each do |curr|
if prev.nil?
# Initialize first record with zeros if nil
curr.balance ||= 0
curr.cash_balance ||= 0
curr.holdings_balance ||= 0
else
# Copy previous values for nil fields
curr.balance ||= prev.balance
curr.cash_balance ||= prev.cash_balance
curr.holdings_balance ||= prev.holdings_balance
end
gapfilled << curr
prev = curr
end
gapfilled
@@ -92,11 +126,21 @@ module Account::Chartable
classification == "asset" ? "up" : "down"
end
def balance_series(period: Period.last_30_days)
def balance_series(period: Period.last_30_days, view: :balance, interval: nil)
self.class.where(id: self.id).balance_series(
currency: currency,
period: period,
view: view,
interval: interval,
favorable_direction: favorable_direction
)
end
def sparkline_series
cache_key = family.build_cache_key("#{id}_sparkline")
Rails.cache.fetch(cache_key) do
balance_series
end
end
end

View File

@@ -0,0 +1,27 @@
module Account::Convertible
extend ActiveSupport::Concern
def sync_required_exchange_rates
unless requires_exchange_rates?
Rails.logger.info("No exchange rate sync needed for account #{id}")
return
end
affected_row_count = ExchangeRate.sync_provider_rates(
from: currency,
to: target_currency,
start_date: start_date,
)
Rails.logger.info("Synced #{affected_row_count} exchange rates for account #{id}")
end
private
def target_currency
family.currency
end
def requires_exchange_rates?
currency != target_currency
end
end

View File

@@ -1,68 +0,0 @@
class Account::DataEnricher
include Providable
attr_reader :account
def initialize(account)
@account = account
end
def run
total_unenriched = account.entries.account_transactions
.joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
.where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL")
.count
if total_unenriched > 0
batch_size = 50
batches = (total_unenriched.to_f / batch_size).ceil
batches.times do |batch|
EnrichTransactionBatchJob.perform_later(account, batch_size, batch * batch_size)
end
end
end
def enrich_transaction_batch(batch_size = 50, offset = 0)
candidates = account.entries.account_transactions
.includes(entryable: [ :merchant, :category ])
.joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
.where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL")
.offset(offset)
.limit(batch_size)
Rails.logger.info("Enriching batch of #{candidates.count} transactions for account #{account.id} (offset: #{offset})")
merchants = {}
candidates.each do |entry|
begin
info = self.class.synth_provider.enrich_transaction(entry.name).info
next unless info.present?
if info.name.present?
merchant = merchants[info.name] ||= account.family.merchants.find_or_create_by(name: info.name)
if info.icon_url.present?
merchant.icon_url = info.icon_url
end
end
entryable_attributes = { id: entry.entryable_id }
entryable_attributes[:merchant_id] = merchant.id if merchant.present? && entry.entryable.merchant_id.nil?
Account.transaction do
merchant.save! if merchant.present?
entry.update!(
enriched_at: Time.current,
enriched_name: info.name,
entryable_attributes: entryable_attributes
)
end
rescue => e
Rails.logger.warn("Error enriching transaction #{entry.id}: #{e.message}")
end
end
end
end

View File

@@ -0,0 +1,71 @@
module Account::Enrichable
extend ActiveSupport::Concern
def enrich_data
total_unenriched = entries.account_transactions
.joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
.where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL")
.count
if total_unenriched > 0
batch_size = 50
batches = (total_unenriched.to_f / batch_size).ceil
batches.times do |batch|
EnrichTransactionBatchJob.perform_now(self, batch_size, batch * batch_size)
# EnrichTransactionBatchJob.perform_later(self, batch_size, batch * batch_size)
end
end
end
def enrich_transaction_batch(batch_size = 50, offset = 0)
transactions_batch = enrichable_transactions.offset(offset).limit(batch_size)
Rails.logger.info("Enriching batch of #{transactions_batch.count} transactions for account #{id} (offset: #{offset})")
merchants = {}
transactions_batch.each do |transaction|
begin
info = transaction.fetch_enrichment_info
next unless info.present?
if info.name.present?
merchant = merchants[info.name] ||= family.merchants.find_or_create_by(name: info.name)
if info.icon_url.present?
merchant.icon_url = info.icon_url
end
end
Account.transaction do
merchant.save! if merchant.present?
transaction.update!(merchant: merchant) if merchant.present? && transaction.merchant_id.nil?
transaction.entry.update!(
enriched_at: Time.current,
enriched_name: info.name,
)
end
rescue => e
Rails.logger.warn("Error enriching transaction #{transaction.id}: #{e.message}")
end
end
end
private
def enrichable?
family.data_enrichment_enabled? || (linked? && Rails.application.config.app_mode.hosted?)
end
def enrichable_transactions
transactions.active
.includes(:merchant, :category)
.where(
"account_entries.enriched_at IS NULL",
"OR merchant_id IS NULL",
"OR category_id IS NULL"
)
end
end

View File

@@ -31,20 +31,6 @@ class Account::EntrySearch
query
end
def apply_type_filter(scope, types)
return scope if types.blank?
query = scope
if types.include?("income") && !types.include?("expense")
query = query.where("account_entries.amount < 0")
elsif types.include?("expense") && !types.include?("income")
query = query.where("account_entries.amount >= 0")
end
query
end
def apply_amount_filter(scope, amount, amount_operator)
return scope if amount.blank? || amount_operator.blank?
@@ -76,7 +62,6 @@ class Account::EntrySearch
query = scope.joins(:account)
query = self.class.apply_search_filter(query, search)
query = self.class.apply_date_filters(query, start_date, end_date)
query = self.class.apply_type_filter(query, types)
query = self.class.apply_amount_filter(query, amount, amount_operator)
query = self.class.apply_accounts_filter(query, accounts, account_ids)
query

View File

@@ -1,5 +1,5 @@
class Account::Holding < ApplicationRecord
include Monetizable
include Monetizable, Gapfillable
monetize :amount

View File

@@ -0,0 +1,63 @@
class Account::Holding::BaseCalculator
attr_reader :account
def initialize(account)
@account = account
end
def calculate
Rails.logger.tagged(self.class.name) do
holdings = calculate_holdings
Account::Holding.gapfill(holdings)
end
end
private
def portfolio_cache
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account)
end
def empty_portfolio
securities = portfolio_cache.get_securities
securities.each_with_object({}) { |security, hash| hash[security.id] = 0 }
end
def generate_starting_portfolio
empty_portfolio
end
def transform_portfolio(previous_portfolio, trade_entries, direction: :forward)
new_quantities = previous_portfolio.dup
trade_entries.each do |trade_entry|
trade = trade_entry.entryable
security_id = trade.security_id
qty_change = trade.qty
qty_change = qty_change * -1 if direction == :reverse
new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change
end
new_quantities
end
def build_holdings(portfolio, date)
portfolio.map do |security_id, qty|
price = portfolio_cache.get_price(security_id, date)
if price.nil?
Rails.logger.warn "No price found for security #{security_id} on #{date}"
next
end
Account::Holding.new(
account_id: account.id,
security_id: security_id,
date: date,
qty: qty,
price: price.price,
currency: price.currency,
amount: qty * price.price
)
end.compact
end
end

View File

@@ -0,0 +1,21 @@
class Account::Holding::ForwardCalculator < Account::Holding::BaseCalculator
private
def portfolio_cache
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account)
end
def calculate_holdings
current_portfolio = generate_starting_portfolio
next_portfolio = {}
holdings = []
account.start_date.upto(Date.current).each do |date|
trades = portfolio_cache.get_trades(date: date)
next_portfolio = transform_portfolio(current_portfolio, trades, direction: :forward)
holdings += build_holdings(next_portfolio, date)
current_portfolio = next_portfolio
end
holdings
end
end

View File

@@ -0,0 +1,38 @@
module Account::Holding::Gapfillable
extend ActiveSupport::Concern
class_methods do
def gapfill(holdings)
filled_holdings = []
holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings|
next if security_holdings.empty?
sorted = security_holdings.sort_by(&:date)
previous_holding = sorted.first
sorted.first.date.upto(Date.current) do |date|
holding = security_holdings.find { |h| h.date == date }
if holding
filled_holdings << holding
previous_holding = holding
else
# Create a new holding based on the previous day's data
filled_holdings << Account::Holding.new(
account: previous_holding.account,
security: previous_holding.security,
date: date,
qty: previous_holding.qty,
price: previous_holding.price,
currency: previous_holding.currency,
amount: previous_holding.amount
)
end
end
end
filled_holdings
end
end
end

View File

@@ -0,0 +1,131 @@
class Account::Holding::PortfolioCache
attr_reader :account, :use_holdings
class SecurityNotFound < StandardError
def initialize(security_id, account_id)
super("Security id=#{security_id} not found in portfolio cache for account #{account_id}. This should not happen unless securities were preloaded incorrectly.")
end
end
def initialize(account, use_holdings: false)
@account = account
@use_holdings = use_holdings
load_prices
end
def get_trades(date: nil)
if date.blank?
trades
else
trades.select { |t| t.date == date }
end
end
def get_price(security_id, date)
security = @security_cache[security_id]
raise SecurityNotFound.new(security_id, account.id) unless security
price = security[:prices].select { |p| p.price.date == date }.min_by(&:priority)&.price
return nil unless price
price_money = Money.new(price.price, price.currency)
converted_amount = price_money.exchange_to(account.currency, fallback_rate: 1).amount
Security::Price.new(
security_id: security_id,
date: price.date,
price: converted_amount,
currency: account.currency
)
end
def get_securities
@security_cache.map { |_, v| v[:security] }
end
private
PriceWithPriority = Data.define(:price, :priority)
def trades
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
end
def holdings
@holdings ||= account.holdings.chronological.to_a
end
def collect_unique_securities
unique_securities_from_trades = trades.map(&:entryable).map(&:security).uniq
return unique_securities_from_trades unless use_holdings
unique_securities_from_holdings = holdings.map(&:security).uniq
(unique_securities_from_trades + unique_securities_from_holdings).uniq
end
# Loads all known prices for all securities in the account with priority based on source:
# 1 - DB or provider prices
# 2 - Trade prices
# 3 - Holding prices
def load_prices
@security_cache = {}
securities = collect_unique_securities
Rails.logger.info "Preloading #{securities.size} securities for account #{account.id}"
securities.each do |security|
Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}"
# Load prices from provider to DB
security.sync_provider_prices(start_date: account.start_date)
# High priority prices from DB (synced from provider)
db_prices = security.prices.where(date: account.start_date..Date.current).map do |price|
PriceWithPriority.new(
price: price,
priority: 1
)
end
# Medium priority prices from trades
trade_prices = trades
.select { |t| t.entryable.security_id == security.id }
.map do |trade|
PriceWithPriority.new(
price: Security::Price.new(
security: security,
price: trade.entryable.price,
currency: trade.entryable.currency,
date: trade.date
),
priority: 2
)
end
# Low priority prices from holdings (if applicable)
holding_prices = if use_holdings
holdings.select { |h| h.security_id == security.id }.map do |holding|
PriceWithPriority.new(
price: Security::Price.new(
security: security,
price: holding.price,
currency: holding.currency,
date: holding.date
),
priority: 3
)
end
else
[]
end
@security_cache[security.id] = {
security: security,
prices: db_prices + trade_prices + holding_prices
}
end
end
end

View File

@@ -0,0 +1,38 @@
class Account::Holding::ReverseCalculator < Account::Holding::BaseCalculator
private
# Reverse calculators will use the existing holdings as a source of security ids and prices
# since it is common for a provider to supply "current day" holdings but not all the historical
# trades that make up those holdings.
def portfolio_cache
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account, use_holdings: true)
end
def calculate_holdings
current_portfolio = generate_starting_portfolio
previous_portfolio = {}
holdings = []
Date.current.downto(account.start_date).each do |date|
today_trades = portfolio_cache.get_trades(date: date)
previous_portfolio = transform_portfolio(current_portfolio, today_trades, direction: :reverse)
holdings += build_holdings(current_portfolio, date)
current_portfolio = previous_portfolio
end
holdings
end
# Since this is a reverse sync, we start with today's holdings
def generate_starting_portfolio
holding_quantities = empty_portfolio
todays_holdings = account.holdings.where(date: Date.current)
todays_holdings.each do |holding|
holding_quantities[holding.security_id] = holding.qty
end
holding_quantities
end
end

View File

@@ -0,0 +1,58 @@
class Account::Holding::Syncer
def initialize(account, strategy:)
@account = account
@strategy = strategy
end
def sync_holdings
calculate_holdings
Rails.logger.info("Persisting #{@holdings.size} holdings")
persist_holdings
if strategy == :forward
purge_stale_holdings
end
@holdings
end
private
attr_reader :account, :strategy
def calculate_holdings
@holdings = calculator.calculate
end
def persist_holdings
current_time = Time.now
account.holdings.upsert_all(
@holdings.map { |h| h.attributes
.slice("date", "currency", "qty", "price", "amount", "security_id")
.merge("account_id" => account.id, "updated_at" => current_time) },
unique_by: %i[account_id security_id date currency]
)
end
def purge_stale_holdings
portfolio_security_ids = account.entries.account_trades.map { |entry| entry.entryable.security_id }.uniq
# If there are no securities in the portfolio, delete all holdings
if portfolio_security_ids.empty?
Rails.logger.info("Clearing all holdings (no securities)")
account.holdings.delete_all
else
deleted_count = account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account.start_date, portfolio_security_ids)
Rails.logger.info("Purged #{deleted_count} stale holdings") if deleted_count > 0
end
end
def calculator
if strategy == :reverse
Account::Holding::ReverseCalculator.new(account)
else
Account::Holding::ForwardCalculator.new(account)
end
end
end

View File

@@ -1,183 +0,0 @@
class Account::HoldingCalculator
def initialize(account)
@account = account
@securities_cache = {}
end
def calculate(reverse: false)
preload_securities
calculated_holdings = reverse ? reverse_holdings : forward_holdings
gapfill_holdings(calculated_holdings)
end
private
attr_reader :account, :securities_cache
def reverse_holdings
current_holding_quantities = load_current_holding_quantities
prior_holding_quantities = {}
holdings = []
Date.current.downto(portfolio_start_date).map do |date|
today_trades = trades.select { |t| t.date == date }
prior_holding_quantities = calculate_portfolio(current_holding_quantities, today_trades)
holdings += generate_holding_records(current_holding_quantities, date)
current_holding_quantities = prior_holding_quantities
end
holdings
end
def forward_holdings
prior_holding_quantities = load_empty_holding_quantities
current_holding_quantities = {}
holdings = []
portfolio_start_date.upto(Date.current).map do |date|
today_trades = trades.select { |t| t.date == date }
current_holding_quantities = calculate_portfolio(prior_holding_quantities, today_trades, inverse: true)
holdings += generate_holding_records(current_holding_quantities, date)
prior_holding_quantities = current_holding_quantities
end
holdings
end
def generate_holding_records(portfolio, date)
Rails.logger.info "[HoldingCalculator] Generating holdings for #{portfolio.size} securities on #{date}"
portfolio.map do |security_id, qty|
security = securities_cache[security_id]
if security.blank?
Rails.logger.error "[HoldingCalculator] Security #{security_id} not found in cache for account #{account.id}"
next
end
price = security.dig(:prices)&.find { |p| p.date == date }
if price.blank?
Rails.logger.info "[HoldingCalculator] No price found for security #{security_id} on #{date}"
next
end
converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount
account.holdings.build(
security: security.dig(:security),
date: date,
qty: qty,
price: converted_price,
currency: account.currency,
amount: qty * converted_price
)
end.compact
end
def gapfill_holdings(holdings)
filled_holdings = []
holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings|
next if security_holdings.empty?
sorted = security_holdings.sort_by(&:date)
previous_holding = sorted.first
sorted.first.date.upto(Date.current) do |date|
holding = security_holdings.find { |h| h.date == date }
if holding
filled_holdings << holding
previous_holding = holding
else
# Create a new holding based on the previous day's data
filled_holdings << account.holdings.build(
security: previous_holding.security,
date: date,
qty: previous_holding.qty,
price: previous_holding.price,
currency: previous_holding.currency,
amount: previous_holding.amount
)
end
end
end
filled_holdings
end
def trades
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
end
def portfolio_start_date
trades.first ? trades.first.date - 1.day : Date.current
end
def preload_securities
# Get securities from trades and current holdings
securities = trades.map(&:entryable).map(&:security).uniq
securities += account.holdings.where(date: Date.current).map(&:security)
securities.uniq!
Rails.logger.info "[HoldingCalculator] Preloading #{securities.size} securities for account #{account.id}"
securities.each do |security|
begin
Rails.logger.info "[HoldingCalculator] Loading security: ID=#{security.id} Ticker=#{security.ticker}"
prices = Security::Price.find_prices(
security: security,
start_date: portfolio_start_date,
end_date: Date.current
)
Rails.logger.info "[HoldingCalculator] Found #{prices.size} prices for security #{security.id}"
@securities_cache[security.id] = {
security: security,
prices: prices
}
rescue => e
Rails.logger.error "[HoldingCalculator] Error processing security #{security.id}: #{e.message}"
Rails.logger.error "[HoldingCalculator] Security details: #{security.attributes}"
Rails.logger.error e.backtrace.join("\n")
next # Skip this security and continue with others
end
end
end
def calculate_portfolio(holding_quantities, today_trades, inverse: false)
new_quantities = holding_quantities.dup
today_trades.each do |trade|
security_id = trade.entryable.security_id
qty_change = inverse ? trade.entryable.qty : -trade.entryable.qty
new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change
end
new_quantities
end
def load_empty_holding_quantities
holding_quantities = {}
trades.map { |t| t.entryable.security_id }.uniq.each do |security_id|
holding_quantities[security_id] = 0
end
holding_quantities
end
def load_current_holding_quantities
holding_quantities = load_empty_holding_quantities
account.holdings.where(date: Date.current, currency: account.currency).map do |holding|
holding_quantities[holding.security_id] = holding.qty
end
holding_quantities
end
end

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