Compare commits

...

83 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
422 changed files with 8708 additions and 4767 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,32 +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 have verified that I am running the **latest** version of the Maybe app (your app should be running [this version](https://github.com/maybe-finance/maybe/pkgs/container/maybe) before opening a bug)
- [ ] 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 ./

11
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 "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)
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.5.0)
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,13 +572,17 @@ 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
stimulus-rails
stripe

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,7 +8,7 @@
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@import "../stylesheets/simonweb_pickr.css";
@import "./simonweb_pickr.css";
@layer components {
.pcr-app{
@@ -112,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 {
@@ -141,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,6 +490,15 @@
details>summary {
@apply list-none;
}
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;
}
}
}
@layer components {
@@ -341,31 +509,63 @@
}
.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;
@@ -375,25 +575,25 @@
}
option:checked {
@apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2;
@apply after:content-['\2713'] bg-container-inset after:text-gray-500 after:ml-2;
}
option:active,
option:focus {
@apply bg-white;
@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;
@@ -403,11 +603,11 @@
}
.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 */
@@ -455,4 +655,109 @@
.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;
}
}

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

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

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

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

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

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

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,8 +1,8 @@
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
@@ -10,7 +10,7 @@ class PagesController < ApplicationController
end
def changelog
@release_notes = Provider::Github.new.fetch_latest_release_notes
@release_notes = github_provider.fetch_latest_release_notes
render layout: "settings"
end
@@ -26,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_provider({
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,26 +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
@@ -46,11 +36,7 @@ class Settings::HostingsController < ApplicationController
private
def hosting_params
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key)
end
def raise_if_not_self_hosted
raise "Settings not available on non-self-hosted instance" unless self_hosted?
params.require(:setting).permit(:require_invite_for_signup, :require_email_confirmation, :synth_api_key)
end
def ensure_admin

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

@@ -17,11 +17,19 @@ 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
@@ -66,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

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

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

@@ -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-80");
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

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

@@ -1,5 +1,5 @@
class DataCacheClearJob < ApplicationJob
queue_as :default
queue_as :low_priority
def perform(family)
ActiveRecord::Base.transaction do

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

@@ -1,5 +1,5 @@
class FamilyResetJob < ApplicationJob
queue_as :default
queue_as :low_priority
def perform(family)
# Delete all family data except users

View File

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

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,13 +2,17 @@ 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
}
])
@@ -21,8 +25,8 @@ module Account::Chartable
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
)
)
@@ -31,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
@@ -52,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})
@@ -70,26 +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
@@ -100,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,66 +0,0 @@
class Account::DataEnricher
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 = entry.fetch_enrichment_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

@@ -1,5 +1,5 @@
class Account::Entry < ApplicationRecord
include Monetizable, Provided
include Monetizable
monetize :amount

View File

@@ -1,11 +0,0 @@
module Account::Entry::Provided
extend ActiveSupport::Concern
include Synthable
def fetch_enrichment_info
return nil unless synth_client.present?
synth_client.enrich_transaction(name).info
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

View File

@@ -0,0 +1,18 @@
module Account::Linkable
extend ActiveSupport::Concern
included do
belongs_to :plaid_account, optional: true
end
# A "linked" account gets transaction and balance data from a third party like Plaid
def linked?
plaid_account_id.present?
end
# An "offline" or "unlinked" account is one where the user tracks values and
# adds transactions manually, without the help of a data provider
def unlinked?
!linked?
end
end

View File

@@ -1,134 +0,0 @@
class Account::Syncer
def initialize(account, start_date: nil)
@account = account
@start_date = start_date
end
def run
account.family.auto_match_transfers!
holdings = sync_holdings
balances = sync_balances(holdings)
account.reload
update_account_info(balances, holdings) unless account.plaid_account_id.present?
convert_records_to_family_currency(balances, holdings) unless account.currency == account.family.currency
# Enrich if user opted in or if we're syncing transactions from a Plaid account on the hosted app
if account.family.data_enrichment_enabled? || (account.plaid_account_id.present? && Rails.application.config.app_mode.hosted?)
account.enrich_data
else
Rails.logger.info("Data enrichment is disabled, skipping enrichment for account #{account.id}")
end
end
private
attr_reader :account, :start_date
def account_start_date
@account_start_date ||= (account.entries.chronological.first&.date || Date.current) - 1.day
end
def update_account_info(balances, holdings)
new_balance = balances.sort_by(&:date).last.balance
new_holdings_value = holdings.select { |h| h.date == Date.current }.sum(&:amount)
new_cash_balance = new_balance - new_holdings_value
account.update!(
balance: new_balance,
cash_balance: new_cash_balance
)
end
def sync_holdings
calculator = Account::HoldingCalculator.new(account)
calculated_holdings = calculator.calculate(reverse: account.plaid_account_id.present?)
current_time = Time.now
Account.transaction do
load_holdings(calculated_holdings)
# Purge outdated holdings
account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, calculated_holdings.map(&:security_id))
end
calculated_holdings
end
def sync_balances(holdings)
calculator = Account::BalanceCalculator.new(account, holdings: holdings)
calculated_balances = calculator.calculate(reverse: account.plaid_account_id.present?, start_date: start_date)
Account.transaction do
load_balances(calculated_balances)
# Purge outdated balances
account.balances.delete_by("date < ?", account_start_date)
end
calculated_balances
end
def convert_records_to_family_currency(balances, holdings)
from_currency = account.currency
to_currency = account.family.currency
exchange_rates = ExchangeRate.find_rates(
from: from_currency,
to: to_currency,
start_date: balances.min_by(&:date).date
)
converted_balances = balances.map do |balance|
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
next unless exchange_rate.present?
account.balances.build(
date: balance.date,
balance: exchange_rate.rate * balance.balance,
currency: to_currency
)
end.compact
converted_holdings = holdings.map do |holding|
exchange_rate = exchange_rates.find { |er| er.date == holding.date }
next unless exchange_rate.present?
account.holdings.build(
security: holding.security,
date: holding.date,
qty: holding.qty,
price: exchange_rate.rate * holding.price,
amount: exchange_rate.rate * holding.amount,
currency: to_currency
)
end.compact
Account.transaction do
load_balances(converted_balances)
load_holdings(converted_holdings)
end
end
def load_balances(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 load_holdings(holdings = [])
current_time = Time.now
account.holdings.upsert_all(
holdings.map { |h| h.attributes
.slice("date", "currency", "qty", "price", "amount", "security_id")
.merge("updated_at" => current_time) },
unique_by: %i[account_id security_id date currency]
)
end
end

View File

@@ -1,5 +1,5 @@
class Account::Transaction < ApplicationRecord
include Account::Entryable, Transferable
include Account::Entryable, Transferable, Provided
belongs_to :category, optional: true
belongs_to :merchant, optional: true

View File

@@ -0,0 +1,20 @@
module Account::Transaction::Provided
extend ActiveSupport::Concern
def fetch_enrichment_info
return nil unless provider
response = provider.enrich_transaction(
entry.name,
amount: entry.amount,
date: entry.date
)
response.data
end
private
def provider
Provider::Registry.get_provider(:synth)
end
end

View File

@@ -14,39 +14,88 @@ class Account::TransactionSearch
attribute :merchants, array: true
attribute :tags, array: true
# Returns array of Account::Entry objects to stay consistent with partials, which only deal with Account::Entry
def build_query(scope)
query = scope.joins(entry: :account)
.joins(transfer_join)
if types.present? && types.exclude?("transfer")
query = query.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_entries.id OR transfers.outflow_transaction_id = account_entries.id")
.where("transfers.id IS NULL")
end
if categories.present?
if categories.exclude?("Uncategorized")
query = query
.joins(:category)
.where(categories: { name: categories })
else
query = query
.left_joins(:category)
.where(categories: { name: categories })
.or(query.where(category_id: nil))
end
end
query = query.joins(:merchant).where(merchants: { name: merchants }) if merchants.present?
query = query.joins(:tags).where(tags: { name: tags }) if tags.present?
# Apply common entry search filters
query = apply_category_filter(query, categories)
query = apply_type_filter(query, types)
query = apply_merchant_filter(query, merchants)
query = apply_tag_filter(query, tags)
query = Account::EntrySearch.apply_search_filter(query, search)
query = Account::EntrySearch.apply_date_filters(query, start_date, end_date)
query = Account::EntrySearch.apply_type_filter(query, types)
query = Account::EntrySearch.apply_amount_filter(query, amount, amount_operator)
query = Account::EntrySearch.apply_accounts_filter(query, accounts, account_ids)
query
end
private
def transfer_join
<<~SQL
LEFT JOIN (
SELECT t.*, t.id as transfer_id, a.accountable_type
FROM transfers t
JOIN account_entries ae ON ae.entryable_id = t.inflow_transaction_id
AND ae.entryable_type = 'Account::Transaction'
JOIN accounts a ON a.id = ae.account_id
) transfer_info ON (
transfer_info.inflow_transaction_id = account_transactions.id OR
transfer_info.outflow_transaction_id = account_transactions.id
)
SQL
end
def apply_category_filter(query, categories)
return query unless categories.present?
query = query.left_joins(:category).where(
"categories.name IN (?) OR (
categories.id IS NULL AND (transfer_info.transfer_id IS NULL OR transfer_info.accountable_type = 'Loan')
)",
categories
)
if categories.exclude?("Uncategorized")
query = query.where.not(category_id: nil)
end
query
end
def apply_type_filter(query, types)
return query unless types.present?
return query if types.sort == [ "expense", "income", "transfer" ]
transfer_condition = "transfer_info.transfer_id IS NOT NULL"
expense_condition = "account_entries.amount >= 0"
income_condition = "account_entries.amount <= 0"
condition = case types.sort
when [ "transfer" ]
transfer_condition
when [ "expense" ]
Arel.sql("#{expense_condition} AND NOT (#{transfer_condition})")
when [ "income" ]
Arel.sql("#{income_condition} AND NOT (#{transfer_condition})")
when [ "expense", "transfer" ]
Arel.sql("#{expense_condition} OR #{transfer_condition}")
when [ "income", "transfer" ]
Arel.sql("#{income_condition} OR #{transfer_condition}")
when [ "expense", "income" ]
Arel.sql("NOT (#{transfer_condition})")
end
query.where(condition)
end
def apply_merchant_filter(query, merchants)
return query unless merchants.present?
query.joins(:merchant).where(merchants: { name: merchants })
end
def apply_tag_filter(query, tags)
return query unless tags.present?
query.joins(:tags).where(tags: { name: tags })
end
end

75
app/models/assistant.rb Normal file
View File

@@ -0,0 +1,75 @@
class Assistant
include Provided, Configurable, Broadcastable
attr_reader :chat, :instructions
class << self
def for_chat(chat)
config = config_for(chat)
new(chat, instructions: config[:instructions], functions: config[:functions])
end
end
def initialize(chat, instructions: nil, functions: [])
@chat = chat
@instructions = instructions
@functions = functions
end
def respond_to(message)
assistant_message = AssistantMessage.new(
chat: chat,
content: "",
ai_model: message.ai_model
)
responder = Assistant::Responder.new(
message: message,
instructions: instructions,
function_tool_caller: function_tool_caller,
llm: get_model_provider(message.ai_model)
)
latest_response_id = chat.latest_assistant_response_id
responder.on(:output_text) do |text|
if assistant_message.content.blank?
stop_thinking
Chat.transaction do
assistant_message.append_text!(text)
chat.update_latest_response!(latest_response_id)
end
else
assistant_message.append_text!(text)
end
end
responder.on(:response) do |data|
update_thinking("Analyzing your data...")
if data[:function_tool_calls].present?
assistant_message.tool_calls = data[:function_tool_calls]
latest_response_id = data[:id]
else
chat.update_latest_response!(data[:id])
end
end
responder.respond(previous_response_id: latest_response_id)
rescue => e
stop_thinking
chat.add_error(e)
end
private
attr_reader :functions
def function_tool_caller
function_instances = functions.map do |fn|
fn.new(chat.user)
end
@function_tool_caller ||= FunctionToolCaller.new(function_instances)
end
end

View File

@@ -0,0 +1,12 @@
module Assistant::Broadcastable
extend ActiveSupport::Concern
private
def update_thinking(thought)
chat.broadcast_update target: "thinking-indicator", partial: "chats/thinking_indicator", locals: { chat: chat, message: thought }
end
def stop_thinking
chat.broadcast_remove target: "thinking-indicator"
end
end

View File

@@ -0,0 +1,85 @@
module Assistant::Configurable
extend ActiveSupport::Concern
class_methods do
def config_for(chat)
preferred_currency = Money::Currency.new(chat.user.family.currency)
preferred_date_format = chat.user.family.date_format
{
instructions: default_instructions(preferred_currency, preferred_date_format),
functions: default_functions
}
end
private
def default_functions
[
Assistant::Function::GetTransactions,
Assistant::Function::GetAccounts,
Assistant::Function::GetBalanceSheet,
Assistant::Function::GetIncomeStatement
]
end
def default_instructions(preferred_currency, preferred_date_format)
<<~PROMPT
## Your identity
You are a friendly financial assistant for an open source personal finance application called "Maybe", which is short for "Maybe Finance".
## Your purpose
You help users understand their financial data by answering questions about their accounts,
transactions, income, expenses, net worth, and more.
## Your rules
Follow all rules below at all times.
### General rules
- Provide ONLY the most important numbers and insights
- Eliminate all unnecessary words and context
- Ask follow-up questions to keep the conversation going. Help educate the user about their own data and entice them to ask more questions.
- Do NOT add introductions or conclusions
- Do NOT apologize or explain limitations
### Formatting rules
- Format all responses in markdown
- Format all monetary values according to the user's preferred currency
- Format dates in the user's preferred format: #{preferred_date_format}
#### User's preferred currency
Maybe is a multi-currency app where each user has a "preferred currency" setting.
When no currency is specified, use the user's preferred currency for formatting and displaying monetary values.
- Symbol: #{preferred_currency.symbol}
- ISO code: #{preferred_currency.iso_code}
- Default precision: #{preferred_currency.default_precision}
- Default format: #{preferred_currency.default_format}
- Separator: #{preferred_currency.separator}
- Delimiter: #{preferred_currency.delimiter}
### Rules about financial advice
You are NOT a licensed financial advisor and therefore, you should not provide any specific investment advice (such as "buy this stock", "sell that bond", "invest in crypto", etc.).
Instead, you should focus on educating the user about personal finance using their own data so they can make informed decisions.
- Do not suggest investments or financial products
- Do not make assumptions about the user's financial situation. Use the functions available to get the data you need.
### Function calling rules
- Use the functions available to you to get user financial data and enhance your responses
- For functions that require dates, use the current date as your reference point: #{Date.current}
- If you suspect that you do not have enough data to 100% accurately answer, be transparent about it and state exactly what
the data you're presenting represents and what context it is in (i.e. date range, account, etc.)
PROMPT
end
end
end

View File

@@ -0,0 +1,92 @@
class Assistant::Function
class << self
def name
raise NotImplementedError, "Subclasses must implement the name class method"
end
def description
raise NotImplementedError, "Subclasses must implement the description class method"
end
end
def initialize(user)
@user = user
end
def call(params = {})
raise NotImplementedError, "Subclasses must implement the call method"
end
def name
self.class.name
end
def description
self.class.description
end
def params_schema
build_schema
end
# (preferred) when in strict mode, the schema needs to include all properties in required array
def strict_mode?
true
end
def to_definition
{
name: name,
description: description,
params_schema: params_schema,
strict: strict_mode?
}
end
private
attr_reader :user
def build_schema(properties: {}, required: [])
{
type: "object",
properties: properties,
required: required,
additionalProperties: false
}
end
def family_account_names
@family_account_names ||= family.accounts.active.pluck(:name)
end
def family_category_names
@family_category_names ||= begin
names = family.categories.pluck(:name)
names << "Uncategorized"
names
end
end
def family_merchant_names
@family_merchant_names ||= family.merchants.pluck(:name)
end
def family_tag_names
@family_tag_names ||= family.tags.pluck(:name)
end
def family
user.family
end
# To save tokens, we provide the AI metadata about the series and a flat array of
# raw, formatted values which it can infer dates from
def to_ai_time_series(series)
{
start_date: series.start_date,
end_date: series.end_date,
interval: series.interval,
values: series.values.map { |v| v.trend.current.format }
}
end
end

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