Compare commits

...

51 Commits

Author SHA1 Message Date
Zach Gollwitzer
64a43d40d1 Fix current market price error for securities 2025-06-24 11:14:15 -04:00
Zach Gollwitzer
fcf14f5f27 Add pre-pull request flow for Claude code 2025-06-20 17:33:03 -04:00
Zach Gollwitzer
63d8114b05 Separate exclude and one-time transaction handling (#2400)
* Separate exclude and one-time transaction handling

- Split transaction "exclude" and "one-time" toggles into separate controls in transaction detail view
- Updated Transaction::Search to show excluded transactions with grayed-out styling instead of filtering them out
- Modified IncomeStatement calculations to exclude both excluded and one_time transactions from totals
- Added migration to convert existing excluded transactions to also be one_time for backward compatibility
- Updated transaction list view to show asterisk for one_time transactions and gray out excluded ones
- Added controller support for kind parameter in transaction updates

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix linting issues

- Remove trailing whitespace from migration
- Fix ERB formatting throughout templates

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-06-20 17:10:36 -04:00
Zach Gollwitzer
c003e8c6ed Transfer match truncation fix 2025-06-20 14:08:46 -04:00
Zach Gollwitzer
ab1c17ea14 Fix transaction layout alignment regression 2025-06-20 13:58:24 -04:00
Zach Gollwitzer
1aae00f586 perf(transactions): add kind to Transaction model and remove expensive Transfer joins in aggregations (#2388)
* add kind to transaction model

* Basic transfer creator

* Fix method naming conflict

* Creator form pattern

* Remove stale methods

* Tweak migration

* Remove BaseQuery, write entire query in each class for clarity

* Query optimizations

* Remove unused exchange rate query lines

* Remove temporary cache-warming strategy

* Fix test

* Update transaction search

* Decouple transactions endpoint from IncomeStatement

* Clean up transactions controller

* Update cursor rules

* Cleanup comments, logic in search

* Fix totals logic on transactions view

* Fix pagination

* Optimize search totals query

* Default to last 30 days on transactions page if no filters

* Decouple transactions list from transfer details

* Revert transfer route

* Migration reset

* Bundle update

* Fix matching logic, tests

* Remove unused code
2025-06-20 13:31:58 -04:00
Josh Pigford
7aca5a2277 Fix remaining rubocop offenses
- Fix string literal style in doorkeeper.rb
- Add missing final newlines
- Remove trailing whitespace
- Fix array bracket spacing in migrations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-18 08:38:04 -05:00
Josh Pigford
8296e10246 Fix linting issues and update API key test for source validation
- Remove trailing whitespace in auth controller and mobile device model
- Update API key test to expect new validation message with source

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-18 08:28:32 -05:00
Josh Pigford
9336719242 Add secure OAuth2-based mobile authentication
- Replace API keys with OAuth2 tokens for mobile apps
- Add device tracking and management for mobile sessions
- Implement 30-day token expiration with refresh tokens
- Add MFA/2FA support for mobile login
- Create dedicated auth endpoints (signup/login/refresh)
- Skip CSRF protection for API endpoints
- Return plaintext tokens (not hashed) in responses
- Track devices with unique IDs and metadata
- Enable seamless native mobile experience without OAuth redirects

This provides enterprise-grade security for the iOS/Android apps while maintaining a completely native authentication flow.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-18 08:20:22 -05:00
Josh Pigford
cba0bdf0e2 Fix OAuth mobile app support with custom URL schemes
- Configure Doorkeeper to allow custom URL schemes (maybeapp://)
- Disable force_ssl_in_redirect_uri to support non-HTTPS schemes
- Add custom Doorkeeper views with mobile OAuth detection
- Disable Turbo for mobile OAuth flows to prevent redirect interference
- Add display parameter preservation through OAuth flow
- Create custom Doorkeeper layouts with proper styling
- Add comprehensive integration tests for mobile OAuth flows
- Ensure all OAuth pages use proper doorkeeper/application layout

This allows the mobile app to complete OAuth authorization flows
without the web app interfering with custom URL scheme redirects.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-18 05:38:23 -05:00
Josh Pigford
404066eaa1 Fix rubocop linting issues in API chat endpoints
- Fix trailing whitespace
- Add missing final newlines
- Fix array bracket spacing
- Auto-corrected all layout issues

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-18 04:36:31 -05:00
Josh Pigford
94202b2a6b Add API v1 chat endpoints
- Add chats#index and chats#show endpoints to list and view AI conversations
- Add messages#create endpoint to send messages to AI chats
- Include API documentation for chat endpoints
- Add controller tests for new endpoints

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-18 04:32:14 -05:00
Josh Pigford
4d3c710291 Fix Active Record encryption for self-hosted deployments
Auto-generate encryption keys based on SECRET_KEY_BASE when not provided.
This ensures API key encryption works out of the box for self-hosted users
without requiring manual setup steps.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-18 04:31:10 -05:00
Josh Pigford
b65e4d376e Fix trailing whitespace in API keys system test 2025-06-17 16:22:09 -05:00
Josh Pigford
fc921c0cd2 Fix system test failures in API keys and trades tests
- Fix API key scopes validation in test (only one scope allowed)
- Update validation error test to match actual behavior
- Fix regenerating API key test path assertion
- Fix revoke confirmation dialog test to work with custom modal
- Fix trades test ticker symbol reference
- Add small delays for modal animations in system tests

All 59 system tests now pass.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 16:20:01 -05:00
Josh Pigford
b803ddac96 Add comprehensive API v1 with OAuth and API key authentication (#2389)
* OAuth

* Add API test routes and update Doorkeeper token handling for test environment

- Introduced API namespace with test routes for controller testing in the test environment.
- Updated Doorkeeper configuration to allow fallback to plain tokens in the test environment for easier testing.
- Modified schema to change resource_owner_id type from bigint to string.

* Implement API key authentication and enhance access control

- Replaced Doorkeeper OAuth authentication with a custom method supporting both OAuth and API keys in the BaseController.
- Added methods for API key authentication, including validation and logging.
- Introduced scope-based authorization for API keys in the TestController.
- Updated routes to include API key management endpoints.
- Enhanced logging for API access to include authentication method details.
- Added tests for API key functionality, including validation, scope checks, and access control enforcement.

* Add API key rate limiting and usage tracking

- Implemented rate limiting for API key authentication in BaseController.
- Added methods to check rate limits, render appropriate responses, and include rate limit headers in responses.
- Updated routes to include a new usage resource for tracking API usage.
- Enhanced tests to verify rate limit functionality, including exceeding limits and per-key tracking.
- Cleaned up Redis data in tests to ensure isolation between test cases.

* Add Jbuilder for JSON rendering and refactor AccountsController

- Added Jbuilder gem for improved JSON response handling.
- Refactored index action in AccountsController to utilize Jbuilder for rendering JSON.
- Removed manual serialization of accounts and streamlined response structure.
- Implemented a before_action in BaseController to enforce JSON format for all API requests.

* Add transactions resource to API routes

- Added routes for transactions, allowing index, show, create, update, and destroy actions.
- This enhancement supports comprehensive transaction management within the API.

* Enhance API authentication and onboarding handling

- Updated BaseController to skip onboarding requirements for API endpoints and added manual token verification for OAuth authentication.
- Improved error handling and logging for invalid access tokens.
- Introduced a method to set up the current context for API requests, ensuring compatibility with session-like behavior.
- Excluded API paths from onboarding redirects in the Onboardable concern.
- Updated database schema to change resource_owner_id type from bigint to string for OAuth access grants.

* Fix rubocop offenses

- Fix indentation and spacing issues
- Convert single quotes to double quotes
- Add spaces inside array brackets
- Fix comment alignment
- Add missing trailing newlines
- Correct else/end alignment

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix API test failures and improve test reliability

- Fix ApiRateLimiterTest by removing mock users method and using fixtures
- Fix UsageControllerTest by removing mock users method and using fixtures
- Fix BaseControllerTest by using different users for multiple API keys
- Use unique display_key values with SecureRandom to avoid conflicts
- Fix double render issue in UsageController by returning after authorize_scope\!
- Specify controller name in routes for usage resource
- Remove trailing whitespace and empty lines per Rubocop

All tests now pass and linting is clean.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add API transactions controller warning to brakeman ignore

The account_id parameter in the API transactions controller is properly
validated on line 79: family.accounts.find(transaction_params[:account_id])
This ensures users can only create transactions in accounts belonging to
their family, making this a false positive.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Signed-off-by: Josh Pigford <josh@joshpigford.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-06-17 15:57:05 -05:00
Zach Gollwitzer
13a64a1694 Fix failing CI 2025-06-16 11:07:38 -04:00
Kenrick Tandrian
b900cc9272 Fix: ticker combobox background and text color (#2370)
* fix(ui): hotwire combobox bg color

* fix(ui): text color
2025-06-16 09:58:37 -04:00
dependabot[bot]
dc505cfcff Bump rack-mini-profiler from 3.3.1 to 4.0.0 (#2381)
Bumps [rack-mini-profiler](https://github.com/MiniProfiler/rack-mini-profiler) from 3.3.1 to 4.0.0.
- [Release notes](https://github.com/MiniProfiler/rack-mini-profiler/releases)
- [Changelog](https://github.com/MiniProfiler/rack-mini-profiler/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MiniProfiler/rack-mini-profiler/compare/v3.3.1...v4.0.0)

---
updated-dependencies:
- dependency-name: rack-mini-profiler
  dependency-version: 4.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>
2025-06-16 09:05:00 -04:00
dependabot[bot]
96ac1dd45f Bump lookbook from 2.3.9 to 2.3.10 (#2379)
Bumps [lookbook](https://github.com/lookbook-hq/lookbook) from 2.3.9 to 2.3.10.
- [Release notes](https://github.com/lookbook-hq/lookbook/releases)
- [Commits](https://github.com/lookbook-hq/lookbook/compare/v2.3.9...v2.3.10)

---
updated-dependencies:
- dependency-name: lookbook
  dependency-version: 2.3.10
  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-06-16 09:04:37 -04:00
dependabot[bot]
5a38159c28 Bump plaid from 40.0.0 to 41.0.0 (#2378)
Bumps [plaid](https://github.com/plaid/plaid-ruby) from 40.0.0 to 41.0.0.
- [Release notes](https://github.com/plaid/plaid-ruby/releases)
- [Changelog](https://github.com/plaid/plaid-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/plaid/plaid-ruby/compare/v40.0.0...v41.0.0)

---
updated-dependencies:
- dependency-name: plaid
  dependency-version: 41.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>
2025-06-16 09:04:30 -04:00
dependabot[bot]
38cad49d6c Bump sentry-ruby from 5.24.0 to 5.25.0 (#2382)
Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 5.24.0 to 5.25.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.24.0...5.25.0)

---
updated-dependencies:
- dependency-name: sentry-ruby
  dependency-version: 5.25.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>
2025-06-16 09:04:23 -04:00
dependabot[bot]
968cd7981a Bump aasm from 5.5.0 to 5.5.1 (#2384)
Bumps [aasm](https://github.com/aasm/aasm) from 5.5.0 to 5.5.1.
- [Changelog](https://github.com/aasm/aasm/blob/master/CHANGELOG.md)
- [Commits](https://github.com/aasm/aasm/commits/v5.5.1)

---
updated-dependencies:
- dependency-name: aasm
  dependency-version: 5.5.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>
2025-06-16 09:04:14 -04:00
Zach Gollwitzer
6d9bb7f0eb Temporary transactions page performance fix (#2372)
* Temporary transactions page performance fix

* Fix Cursor bugs

* More bugbot bug fixes
2025-06-15 11:36:21 -04:00
Zach Gollwitzer
a5f1677f60 perf(income statement): cache income statement queries (#2371)
* Leftover cleanup from prior PR

* Benchmark convenience task

* Change default warm benchmark time

* Cache income statement queries

* Fix private method access
2025-06-15 10:09:46 -04:00
Zach Gollwitzer
84b2426e54 Benchmarking setup (#2366)
* Benchmarking setup

* Get demo data working in benchmark scenario

* Finalize default demo scenario

* Finalize benchmarking setup
2025-06-14 11:53:53 -04:00
Huy Nguyen Quang
cdad31812a Fix user deletion foreign key constraint with invitations (#2357) 2025-06-11 20:26:31 -05:00
Zach Gollwitzer
5a4c955522 Realistic demo data for performance testing (#2361)
* Realistic demo data for performance testing

* Add note about performance testing

* Fix bugbot issues

* More realistic account values
2025-06-11 18:48:39 -04:00
Zach Gollwitzer
0d62e60da1 Fix stale reference to classification group name 2025-06-10 21:30:53 -04:00
Zach Gollwitzer
10ce2c8e23 Balance sheet cache layer, non-blocking sync UI (#2356)
* Balance sheet cache layer with cache-busting

* Update family cache timestamps during Sync

* Less blocking sync loaders

* Consolidate family data caching key logic

* Fix turbo stream broadcasts

* Remove dev delay

* Add back account group sorting
2025-06-10 18:20:06 -04:00
Josh Pigford
dab693d74f Logtail updates 2025-06-10 05:10:57 -05:00
Zach Gollwitzer
019a0d873c Fix dark mode text hover styles 2025-06-09 18:39:04 -04:00
Zach Gollwitzer
9fabcf4c72 Redis check for self hosted apps (#2353)
* Redis check for self hosted apps

* Run linter with autocorrect

* Add Redis to CI
2025-06-09 18:30:52 -04:00
Zach Gollwitzer
4044a8519f Add account sync button back to self hosted instances 2025-06-09 11:35:59 -04:00
Zach Gollwitzer
9afc50a146 Fix merchant editing (#2349) 2025-06-09 10:50:56 -04:00
Tony Tkachenko
0063921de9 fix(ui): mfa backup codes dark mode (#2323)
* fix(ui): mfa backup codes dark mode

* Update app/views/mfa/backup_codes.html.erb

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

---------

Signed-off-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
2025-06-09 10:19:09 -04:00
Iuri G.
1d2e7fcae0 perf: Add index to sync status (#2337)
* - Add index to sync status

* - revert typo

* - revert unrelated schema.rb change
2025-06-09 10:18:52 -04:00
Zach Gollwitzer
9f6c9b4057 Update deps 2025-06-09 09:53:32 -04:00
Adam M. Goyer
d05946596e Fix typo in docker hosting documentation (#2318) 2025-06-03 05:14:25 -05:00
Josh Pigford
a76cc2dff8 Configure PlaidSandbox to use sandbox environment regardless of Rails config and set test environment variables for Plaid. Temporarily disable AutoSync functionality in tests. 2025-06-01 06:37:46 -05:00
Josh Pigford
870b543640 Refactor syncing? method in Family model to optimize query performance. Moved visible scope to the beginning and adjusted joins and where conditions to leverage composite indexing for improved efficiency. 2025-06-01 06:30:38 -05:00
Josh Pigford
1f8a994b4e Comment out auto-sync callback in AutoSync concern to disable family synchronization temporarily. 2025-06-01 06:17:11 -05:00
Josh Pigford
ee9fe1b62d Update README.md
Signed-off-by: Josh Pigford <josh@joshpigford.com>
2025-05-27 11:08:59 -05:00
Josh Pigford
4f5068e7e5 feat(assistant): improve chat functionality and update tests - refactor configurable model, update OpenAI provider, enhance chat form UI, and improve test coverage (#2316)
Updated model to GPT 4.1
2025-05-27 05:04:58 -05:00
Josh Pigford
e7f1506728 Refactor sparkline error handling and improve series pre-loading
- Added pre-loading of series in AccountableSparklinesController and AccountsController to catch errors before rendering.
- Updated the accounts view to use the pre-loaded sparkline series variable.
- Adjusted the test for graceful handling of errors in the sparkline series method.

This enhances the robustness of the sparkline feature and improves error visibility in the UI.
2025-05-26 20:16:07 -05:00
Josh Pigford
6f67827f14 Implement error handling and logging for sparkline and series methods
- Added rescue blocks to handle exceptions in the Accounts and AccountableSparklines controllers, logging errors and rendering error partials.
- Enhanced error handling in the Account::Chartable and Balance::ChartSeriesBuilder models, logging specific error messages for series generation failures.
- Updated the accounts view to include a timeout for Turbo frame loading.
- Added a test to ensure graceful handling of sparkline errors in the AccountsController.

In reference to bug #2315
2025-05-26 20:05:16 -05:00
Josh Pigford
3cc88f3e98 Fix changelog page crash when GitHub release notes are unavailable (#2314)
* Fix changelog page crash when GitHub release notes are unavailable

* Refactor changelog view to handle missing avatars gracefully and improve session sign-out logic in tests

* Enhance changelog view to display fallback messages for unavailable release notes and publication dates

* Update onboarding system tests to reflect UI changes and improve assertions

- Changed button labels from "Get started" to "Continue" and "Complete" to align with updated UI.
- Updated text assertions for clarity, changing "Set your preferences" to "Configure your preferences".
- Adjusted locale selection options to include language codes.
- Enhanced validation error handling in preferences form.
- Improved navigation assertions to ensure accurate path checks.
2025-05-26 19:53:25 -05:00
Josh Pigford
6dae236fe0 Remove trend initialization from Series model to streamline value calculations.
Added initial pass at onboarding system tests.
2025-05-26 18:59:07 -05:00
Josh Pigford
07ca33f2f4 Taskmaster Stubbing (#2313) 2025-05-26 18:39:39 -05:00
dependabot[bot]
fe33fe086a Bump selenium-webdriver from 4.32.0 to 4.33.0 (#2307)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.32.0 to 4.33.0.
- [Release notes](https://github.com/SeleniumHQ/selenium/releases)
- [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES)
- [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.32.0...selenium-4.33.0)

---
updated-dependencies:
- dependency-name: selenium-webdriver
  dependency-version: 4.33.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>
2025-05-26 06:17:26 -04:00
dependabot[bot]
bf2426ce82 Bump view_component from 3.22.0 to 3.23.2 (#2308)
Bumps [view_component](https://github.com/viewcomponent/view_component) from 3.22.0 to 3.23.2.
- [Release notes](https://github.com/viewcomponent/view_component/releases)
- [Changelog](https://github.com/ViewComponent/view_component/blob/main/docs/CHANGELOG.md)
- [Commits](https://github.com/viewcomponent/view_component/compare/v3.22.0...v3.23.2)

---
updated-dependencies:
- dependency-name: view_component
  dependency-version: 3.23.2
  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-05-26 06:17:18 -04:00
231 changed files with 11695 additions and 1693 deletions

View File

@@ -0,0 +1,53 @@
---
description: Guidelines for creating and maintaining Cursor rules to ensure consistency and effectiveness.
globs: .cursor/rules/*.mdc
alwaysApply: true
---
- **Required Rule Structure:**
```markdown
---
description: Clear, one-line description of what the rule enforces
globs: path/to/files/*.ext, other/path/**/*
alwaysApply: boolean
---
- **Main Points in Bold**
- Sub-points with details
- Examples and explanations
```
- **File References:**
- Use `[filename](mdc:path/to/file)` ([filename](mdc:filename)) to reference files
- Example: [prisma.mdc](mdc:.cursor/rules/prisma.mdc) for rule references
- Example: [schema.prisma](mdc:prisma/schema.prisma) for code references
- **Code Examples:**
- Use language-specific code blocks
```typescript
// ✅ DO: Show good examples
const goodExample = true;
// ❌ DON'T: Show anti-patterns
const badExample = false;
```
- **Rule Content Guidelines:**
- Start with high-level overview
- Include specific, actionable requirements
- Show examples of correct implementation
- Reference existing code when possible
- Keep rules DRY by referencing other rules
- **Rule Maintenance:**
- Update rules when new patterns emerge
- Add examples from actual codebase
- Remove outdated patterns
- Cross-reference related rules
- **Best Practices:**
- Use bullet points for clarity
- Keep descriptions concise
- Include both DO and DON'T examples
- Reference actual code over theoretical examples
- Use consistent formatting across rules

View File

@@ -66,54 +66,7 @@ All code should maximize readability and simplicity.
- 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/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 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
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
@account.expects(:start_date).returns(2.days.ago.to_date)
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
[
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
]
)
assert_difference "@account.balances.count", 2 do
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 = 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
### Convention 5: 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.

View File

@@ -0,0 +1,72 @@
---
description: Guidelines for continuously improving Cursor rules based on emerging code patterns and best practices.
globs: **/*
alwaysApply: true
---
- **Rule Improvement Triggers:**
- New code patterns not covered by existing rules
- Repeated similar implementations across files
- Common error patterns that could be prevented
- New libraries or tools being used consistently
- Emerging best practices in the codebase
- **Analysis Process:**
- Compare new code with existing rules
- Identify patterns that should be standardized
- Look for references to external documentation
- Check for consistent error handling patterns
- Monitor test patterns and coverage
- **Rule Updates:**
- **Add New Rules When:**
- A new technology/pattern is used in 3+ files
- Common bugs could be prevented by a rule
- Code reviews repeatedly mention the same feedback
- New security or performance patterns emerge
- **Modify Existing Rules When:**
- Better examples exist in the codebase
- Additional edge cases are discovered
- Related rules have been updated
- Implementation details have changed
- **Example Pattern Recognition:**
```typescript
// If you see repeated patterns like:
const data = await prisma.user.findMany({
select: { id: true, email: true },
where: { status: 'ACTIVE' }
});
// Consider adding to [prisma.mdc](mdc:.cursor/rules/prisma.mdc):
// - Standard select fields
// - Common where conditions
// - Performance optimization patterns
```
- **Rule Quality Checks:**
- Rules should be actionable and specific
- Examples should come from actual code
- References should be up to date
- Patterns should be consistently enforced
- **Continuous Improvement:**
- Monitor code review comments
- Track common development questions
- Update rules after major refactors
- Add links to relevant documentation
- Cross-reference related rules
- **Rule Deprecation:**
- Mark outdated patterns as deprecated
- Remove rules that no longer apply
- Update references to deprecated rules
- Document migration paths for old patterns
- **Documentation Updates:**
- Keep examples synchronized with code
- Update references to external docs
- Maintain links between related rules
- Document breaking changes
Follow [cursor_rules.mdc](mdc:.cursor/rules/cursor_rules.mdc) for proper rule formatting and structure.

View File

@@ -0,0 +1,64 @@
---
description:
globs:
alwaysApply: false
---
This rule describes how to write Stimulus controllers.
- **Use declarative actions, not imperative event listeners**
- Instead of assigning a Stimulus target and binding it to an event listener in the initializer, always write Controllers + ERB views declaratively by using Stimulus actions in ERB to call methods in the Stimulus JS controller. Below are good vs. bad code.
BAD code:
```js
// BAD!!!! DO NOT DO THIS!!
// Imperative - controller does all the work
export default class extends Controller {
static targets = ["button", "content"]
connect() {
this.buttonTarget.addEventListener("click", this.toggle.bind(this))
}
toggle() {
this.contentTarget.classList.toggle("hidden")
this.buttonTarget.textContent = this.contentTarget.classList.contains("hidden") ? "Show" : "Hide"
}
}
```
GOOD code:
```erb
<!-- Declarative - HTML declares what happens -->
<div data-controller="toggle">
<button data-action="click->toggle#toggle" data-toggle-target="button">Show</button>
<div data-toggle-target="content" class="hidden">Hello World!</div>
</div>
```
```js
// Declarative - controller just responds
export default class extends Controller {
static targets = ["button", "content"]
toggle() {
this.contentTarget.classList.toggle("hidden")
this.buttonTarget.textContent = this.contentTarget.classList.contains("hidden") ? "Show" : "Hide"
}
}
```
- **Keep Stimulus controllers lightweight and simple**
- Always aim for less than 7 controller targets. Any more is a sign of too much complexity.
- Use private methods and expose a clear public API
- **Keep Stimulus controllers focused on what they do best**
- Domain logic does NOT belong in a Stimulus controller
- Stimulus controllers should aim for a single responsibility, or a group of highly related responsibilities
- Make good use of Stimulus's callbacks, actions, targets, values, and classes
- **Component controllers should not be used outside the component**
- If a Stimulus controller is in the app/components directory, it should only be used in its component view. It should not be used anywhere in app/views.

87
.cursor/rules/testing.mdc Normal file
View File

@@ -0,0 +1,87 @@
---
description:
globs: test/**
alwaysApply: false
---
Use this rule to learn how to write tests for the Maybe codebase.
Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability.
- **General testing rules**
- Always use Minitest and fixtures for testing, NEVER rspec or factories
- 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 to help create the records needed for the test, then inline the creation. For example, [entries_test_helper.rb](mdc:test/support/entries_test_helper.rb) provides helpers to easily do this.
- **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
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
@account.expects(:start_date).returns(2.days.ago.to_date)
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
[
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
]
)
assert_difference "@account.balances.count", 2 do
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 = Balance.new(balance: 100, currency: "USD")
assert balance_record.save
end
```
- **Test boundaries correctly**
- Distinguish between commands and query methods. Test output of query methods; test that commands were called with the correct params. See an example below:
```rb
class ExampleClass
def do_something
result = 2 + 2
CustomEventProcessor.process_result(result)
result
end
end
class ExampleClass < ActiveSupport::TestCase
test "boundaries are tested correctly" do
result = ExampleClass.new.do_something
# GOOD - we're only testing that the command was received, not internal implementation details
# The actual tests for CustomEventProcessor belong in a different test suite!
CustomEventProcessor.expects(:process_result).with(4).once
# GOOD - we're testing the implementation of ExampleClass inside its own test suite
assert_equal 4, result
end
end
```
- Never test the implementation details of one class in another classes test suite
- **Stubs and mocks**
- Use `mocha` gem
- Always prefer `OpenStruct` when creating mock instances, or in complex cases, a mock class
- Only mock what's necessary. If you're not testing return values, don't mock a return value.

View File

@@ -0,0 +1,100 @@
---
description:
globs: app/views/**,app/javascript/**,app/components/**/*.js
alwaysApply: false
---
Use this rule to learn how to write ERB views, partials, and Stimulus controllers should be incorporated into them.
- **Component vs. Partial Decision Making**
- **Use ViewComponents when:**
- Element has complex logic or styling patterns
- Element will be reused across multiple views/contexts
- Element needs structured styling with variants/sizes (like buttons, badges)
- Element requires interactive behavior or Stimulus controllers
- Element has configurable slots or complex APIs
- Element needs accessibility features or ARIA support
- **Use Partials when:**
- Element is primarily static HTML with minimal logic
- Element is used in only one or few specific contexts
- Element is simple template content (like CTAs, static sections)
- Element doesn't need variants, sizes, or complex configuration
- Element is more about content organization than reusable functionality
- **Prefer components over partials**
- If there is a component available for the use case in app/components, use it
- If there is no component, look for a partial
- If there is no partial, decide between component or partial based on the criteria above
- **Examples of Component vs. Partial Usage**
```erb
<%# Component: Complex, reusable with variants and interactivity %>
<%= render DialogComponent.new(variant: :drawer) do |dialog| %>
<% dialog.with_header(title: "Account Settings") %>
<% dialog.with_body { "Dialog content here" } %>
<% end %>
<%# Component: Interactive with complex styling options %>
<%= render ButtonComponent.new(text: "Save Changes", variant: "primary", confirm: "Are you sure?") %>
<%# Component: Reusable with variants %>
<%= render FilledIconComponent.new(icon: "credit-card", variant: :surface) %>
<%# Partial: Static template content %>
<%= render "shared/logo" %>
<%# Partial: Simple, context-specific content with basic styling %>
<%= render "shared/trend_change", trend: @account.trend, comparison_label: "vs last month" %>
<%# Partial: Simple divider/utility %>
<%= render "shared/ruler", classes: "my-4" %>
<%# Partial: Simple form utility %>
<%= render "shared/form_errors", model: @account %>
```
- **Keep domain logic out of the views**
```erb
<%# BAD!!! %>
<%# This belongs in the component file, not the template file! %>
<% button_classes = { class: "bg-blue-500 hover:bg-blue-600" } %>
<%= tag.button class: button_classes do %>
Save Account
<% end %>
<%# GOOD! %>
<%= tag.button class: computed_button_classes do %>
Save Account
<% end %>
```
- **Stimulus Integration in Views**
- Always use the **declarative approach** when integrating Stimulus controllers
- The ERB template should declare what happens, the Stimulus controller should respond
- Refer to [stimulus_conventions.mdc](mdc:.cursor/rules/stimulus_conventions.mdc) to learn how to incorporate them into
GOOD Stimulus controller integration into views:
```erb
<!-- Declarative - HTML declares what happens -->
<div data-controller="toggle">
<button data-action="click->toggle#toggle" data-toggle-target="button">Show</button>
<div data-toggle-target="content" class="hidden">Hello World!</div>
</div>
```
- **Stimulus Controller Placement Guidelines**
- **Component controllers** (in `app/components/`) should only be used within their component templates
- **Global controllers** (in `app/javascript/controllers/`) can be used across any view
- Pass data from Rails to Stimulus using `data-*-value` attributes, not inline JavaScript
- Use Stimulus targets to reference DOM elements, not manual `getElementById` calls
- **Naming Conventions**
- **Components**: Use `ComponentName` suffix (e.g., `ButtonComponent`, `DialogComponent`, `FilledIconComponent`)
- **Partials**: Use underscore prefix (e.g., `_trend_change.html.erb`, `_form_errors.html.erb`, `_sync_indicator.html.erb`)
- **Shared partials**: Place in `app/views/shared/` directory for reusable content
- **Context-specific partials**: Place in relevant controller view directory (e.g., `accounts/_account_sidebar_tabs.html.erb`)

View File

@@ -51,6 +51,14 @@ APP_DOMAIN=
# Disable enforcing SSL connections
# DISABLE_SSL=true
# Active Record Encryption Keys (Optional)
# These keys are used to encrypt sensitive data like API keys in the database.
# If not provided, they will be automatically generated based on your SECRET_KEY_BASE.
# You can generate your own keys by running: rails db:encryption:init
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
# ======================================================================================================
# Active Storage Configuration - responsible for storing file uploads
# ======================================================================================================

View File

@@ -80,6 +80,7 @@ jobs:
PLAID_CLIENT_ID: foo
PLAID_SECRET: bar
DATABASE_URL: postgres://postgres:postgres@localhost:5432
REDIS_URL: redis://localhost:6379
RAILS_ENV: test
services:
@@ -92,6 +93,12 @@ jobs:
- 5432:5432
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
redis:
image: redis
ports:
- 6379:6379
options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev

37
.gitignore vendored
View File

@@ -70,4 +70,39 @@ node_modules
compose.yml
plaid_test_accounts/
plaid_test_accounts/
# Added by Claude Task Master
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
dev-debug.log
# Dependency directories
node_modules/
# Environment variables
.env
# Editor directories and files
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.roo*
# OS specific
# Task files
.taskmaster/
tasks.json
.taskmaster/tasks/
.taskmaster/reports/
.taskmaster/state.json
*.mcp.json
scripts/
.cursor/mcp.json
.taskmasterconfig
.windsurfrules
.cursor/rules/dev_workflow.mdc
.cursor/rules/taskmaster.mdc

273
CLAUDE.md Normal file
View File

@@ -0,0 +1,273 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Development Commands
### Development Server
- `bin/dev` - Start development server (Rails, Sidekiq, Tailwind CSS watcher)
- `bin/rails server` - Start Rails server only
- `bin/rails console` - Open Rails console
### Testing
- `bin/rails test` - Run all tests
- `bin/rails test:db` - Run tests with database reset
- `bin/rails test:system` - Run system tests only (use sparingly - they take longer)
- `bin/rails test test/models/account_test.rb` - Run specific test file
- `bin/rails test test/models/account_test.rb:42` - Run specific test at line
### Linting & Formatting
- `bin/rubocop` - Run Ruby linter
- `npm run lint` - Check JavaScript/TypeScript code
- `npm run lint:fix` - Fix JavaScript/TypeScript issues
- `npm run format` - Format JavaScript/TypeScript code
- `bin/brakeman` - Run security analysis
### Database
- `bin/rails db:prepare` - Create and migrate database
- `bin/rails db:migrate` - Run pending migrations
- `bin/rails db:rollback` - Rollback last migration
- `bin/rails db:seed` - Load seed data
### Setup
- `bin/setup` - Initial project setup (installs dependencies, prepares database)
## Pre-Pull Request CI Workflow
ALWAYS run these commands before opening a pull request:
1. **Tests** (Required):
- `bin/rails test` - Run all tests (always required)
- `bin/rails test:system` - Run system tests (only when applicable, they take longer)
2. **Linting** (Required):
- `bin/rubocop -f github -a` - Ruby linting with auto-correct
- `bundle exec erb_lint ./app/**/*.erb -a` - ERB linting with auto-correct
3. **Security** (Required):
- `bin/brakeman --no-pager` - Security analysis
Only proceed with pull request creation if ALL checks pass.
## General Development Rules
### Authentication Context
- Use `Current.user` for the current user. Do NOT use `current_user`.
- Use `Current.family` for the current family. Do NOT use `current_family`.
### Development Guidelines
- Prior to generating any code, carefully read the project conventions and guidelines
- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development
- 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
## High-Level Architecture
### Application Modes
The Maybe app runs in two distinct modes:
- **Managed**: The Maybe team operates and manages servers for users (Rails.application.config.app_mode = "managed")
- **Self Hosted**: Users host the Maybe app on their own infrastructure, typically through Docker Compose (Rails.application.config.app_mode = "self_hosted")
### Core Domain Model
The application is built around financial data management with these key relationships:
- **User** → has many **Accounts** → has many **Transactions**
- **Account** types: checking, savings, credit cards, investments, crypto, loans, properties
- **Transaction** → belongs to **Category**, can have **Tags** and **Rules**
- **Investment accounts** → have **Holdings** → track **Securities** via **Trades**
### API Architecture
The application provides both internal and external APIs:
- Internal API: Controllers serve JSON via Turbo for SPA-like interactions
- External API: `/api/v1/` namespace with Doorkeeper OAuth and API key authentication
- API responses use Jbuilder templates for JSON rendering
- Rate limiting via Rack Attack with configurable limits per API key
### Sync & Import System
Two primary data ingestion methods:
1. **Plaid Integration**: Real-time bank account syncing
- `PlaidItem` manages connections
- `Sync` tracks sync operations
- Background jobs handle data updates
2. **CSV Import**: Manual data import with mapping
- `Import` manages import sessions
- Supports transaction and balance imports
- Custom field mapping with transformation rules
### Background Processing
Sidekiq handles asynchronous tasks:
- Account syncing (`SyncAccountsJob`)
- Import processing (`ImportDataJob`)
- AI chat responses (`CreateChatResponseJob`)
- Scheduled maintenance via sidekiq-cron
### Frontend Architecture
- **Hotwire Stack**: Turbo + Stimulus for reactive UI without heavy JavaScript
- **ViewComponents**: Reusable UI components in `app/components/`
- **Stimulus Controllers**: Handle interactivity, organized alongside components
- **Charts**: D3.js for financial visualizations (time series, donut, sankey)
- **Styling**: Tailwind CSS v4.x with custom design system
- Design system defined in `app/assets/tailwind/maybe-design-system.css`
- Always use functional tokens (e.g., `text-primary` not `text-white`)
- Prefer semantic HTML elements over JS components
- Use `icon` helper for icons, never `lucide_icon` directly
### Multi-Currency Support
- All monetary values stored in base currency (user's primary currency)
- Exchange rates fetched from Synth API
- `Money` objects handle currency conversion and formatting
- Historical exchange rates for accurate reporting
### Security & Authentication
- Session-based auth for web users
- API authentication via:
- OAuth2 (Doorkeeper) for third-party apps
- API keys with JWT tokens for direct API access
- Scoped permissions system for API access
- Strong parameters and CSRF protection throughout
### Testing Philosophy
- Comprehensive test coverage using Rails' built-in Minitest
- Fixtures for test data (avoid FactoryBot)
- Keep fixtures minimal (2-3 per model for base cases)
- VCR for external API testing
- System tests for critical user flows (use sparingly)
- Test helpers in `test/support/` for common scenarios
- Only test critical code paths that significantly increase confidence
- Write tests as you go, when required
### Performance Considerations
- Database queries optimized with proper indexes
- N+1 queries prevented via includes/joins
- Background jobs for heavy operations
- Caching strategies for expensive calculations
- Turbo Frames for partial page updates
### Development Workflow
- Feature branches merged to `main`
- Docker support for consistent environments
- Environment variables via `.env` files
- Lookbook for component development (`/lookbook`)
- Letter Opener for email preview in development
## Project Conventions
### Convention 1: Minimize Dependencies
- Push Rails to its limits before adding new dependencies
- Strong technical/business reason required for new dependencies
- Favor old and reliable over new and flashy
### Convention 2: Skinny Controllers, Fat Models
- Business logic in `app/models/` folder, avoid `app/services/`
- Use Rails concerns and POROs for organization
- Models should answer questions about themselves: `account.balance_series` not `AccountSeries.new(account).call`
### Convention 3: Hotwire-First Frontend
- **Native HTML preferred over JS components**
- Use `<dialog>` for modals, `<details><summary>` for disclosures
- **Leverage Turbo frames** for page sections over client-side solutions
- **Query params for state** over localStorage/sessions
- **Server-side formatting** for currencies, numbers, dates
- **Always use `icon` helper** in `application_helper.rb`, NEVER `lucide_icon` directly
### Convention 4: Optimize for Simplicity
- Prioritize good OOP domain design over performance
- Focus performance only on critical/global areas (avoid N+1 queries, mindful of global layouts)
### Convention 5: Database vs ActiveRecord Validations
- Simple validations (null checks, unique indexes) in DB
- ActiveRecord validations for convenience in forms (prefer client-side when possible)
- Complex validations and business logic in ActiveRecord
## TailwindCSS Design System
### Design System Rules
- **Always reference `app/assets/tailwind/maybe-design-system.css`** for primitives and tokens
- **Use functional tokens** defined in design system:
- `text-primary` instead of `text-white`
- `bg-container` instead of `bg-white`
- `border border-primary` instead of `border border-gray-200`
- **NEVER create new styles** in design system files without permission
- **Always generate semantic HTML**
## Component Architecture
### ViewComponent vs Partials Decision Making
**Use ViewComponents when:**
- Element has complex logic or styling patterns
- Element will be reused across multiple views/contexts
- Element needs structured styling with variants/sizes
- Element requires interactive behavior or Stimulus controllers
- Element has configurable slots or complex APIs
- Element needs accessibility features or ARIA support
**Use Partials when:**
- Element is primarily static HTML with minimal logic
- Element is used in only one or few specific contexts
- Element is simple template content
- Element doesn't need variants, sizes, or complex configuration
- Element is more about content organization than reusable functionality
**Component Guidelines:**
- Prefer components over partials when available
- Keep domain logic OUT of view templates
- Logic belongs in component files, not template files
### Stimulus Controller Guidelines
**Declarative Actions (Required):**
```erb
<!-- GOOD: Declarative - HTML declares what happens -->
<div data-controller="toggle">
<button data-action="click->toggle#toggle" data-toggle-target="button">Show</button>
<div data-toggle-target="content" class="hidden">Hello World!</div>
</div>
```
**Controller Best Practices:**
- Keep controllers lightweight and simple (< 7 targets)
- Use private methods and expose clear public API
- Single responsibility or highly related responsibilities
- Component controllers stay in component directory, global controllers in `app/javascript/controllers/`
- Pass data via `data-*-value` attributes, not inline JavaScript
## Testing Philosophy
### General Testing Rules
- **ALWAYS use Minitest + fixtures** (NEVER RSpec or factories)
- Keep fixtures minimal (2-3 per model for base cases)
- Create edge cases on-the-fly within test context
- Use Rails helpers for large fixture creation needs
### Test Quality Guidelines
- **Write minimal, effective tests** - system tests sparingly
- **Only test critical and important code paths**
- **Test boundaries correctly:**
- Commands: test they were called with correct params
- Queries: test output
- Don't test implementation details of other classes
### Testing Examples
```ruby
# GOOD - Testing critical domain business logic
test "syncs balances" do
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
assert_difference "@account.balances.count", 2 do
Balance::Syncer.new(@account, strategy: :forward).sync_balances
end
end
# BAD - Testing ActiveRecord functionality
test "saves balance" do
balance_record = Balance.new(balance: 100, currency: "USD")
assert balance_record.save
end
```
### Stubs and Mocks
- Use `mocha` gem
- Prefer `OpenStruct` for mock instances
- Only mock what's necessary

19
Gemfile
View File

@@ -23,7 +23,11 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails"
gem "stimulus-rails"
gem "turbo-rails"
gem "view_component"
gem "lookbook", ">= 2.3.7"
# https://github.com/lookbook-hq/lookbook/issues/712
# TODO: Remove max version constraint when fixed
gem "lookbook", "2.3.9"
gem "hotwire_combobox"
# Background Jobs
@@ -37,7 +41,7 @@ gem "sentry-ruby"
gem "sentry-rails"
gem "sentry-sidekiq"
gem "logtail-rails"
gem "skylight"
gem "skylight", groups: [ :production ]
# Active Storage
gem "aws-sdk-s3", "~> 1.177.0", require: false
@@ -47,6 +51,11 @@ gem "image_processing", ">= 1.2"
gem "ostruct"
gem "bcrypt", "~> 3.1"
gem "jwt"
gem "jbuilder"
# OAuth & API Security
gem "doorkeeper"
gem "rack-attack", "~> 6.6"
gem "faraday"
gem "faraday-retry"
gem "faraday-multipart"
@@ -80,6 +89,10 @@ group :development, :test do
gem "dotenv-rails"
end
if ENV["BENCHMARKING_ENABLED"]
gem "dotenv-rails", groups: [ :production ]
end
group :development do
gem "hotwire-livereload"
gem "letter_opener"
@@ -87,6 +100,8 @@ group :development do
gem "web-console"
gem "faker"
gem "benchmark-ips"
gem "stackprof"
gem "derailed_benchmarks"
gem "foreman"
end

View File

@@ -8,7 +8,7 @@ GIT
GEM
remote: https://rubygems.org/
specs:
aasm (5.5.0)
aasm (5.5.1)
concurrent-ruby (~> 1.0)
actioncable (7.2.2.1)
actionpack (= 7.2.2.1)
@@ -63,7 +63,7 @@ GEM
activemodel (= 7.2.2.1)
activesupport (= 7.2.2.1)
timeout (>= 0.4.0)
activerecord-import (2.1.0)
activerecord-import (2.2.0)
activerecord (>= 4.2)
activestorage (7.2.2.1)
actionpack (= 7.2.2.1)
@@ -89,27 +89,27 @@ GEM
activerecord (>= 4.2)
activesupport
ast (2.4.3)
aws-eventstream (1.3.2)
aws-partitions (1.1105.0)
aws-sdk-core (3.224.0)
aws-eventstream (1.4.0)
aws-partitions (1.1113.0)
aws-sdk-core (3.225.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.101.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-kms (1.104.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.177.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0)
aws-sigv4 (1.12.0)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.0)
benchmark (0.4.1)
benchmark-ips (2.14.0)
better_html (2.1.1)
actionview (>= 6.0)
@@ -118,7 +118,7 @@ GEM
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (3.1.9)
bigdecimal (3.2.2)
bindex (0.8.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
@@ -138,7 +138,7 @@ GEM
logger (~> 1.5)
chunky_png (1.4.0)
climate_control (1.2.0)
concurrent-ruby (1.3.4)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
crack (1.0.0)
bigdecimal
@@ -149,17 +149,37 @@ GEM
unicode (>= 0.4.4.5)
css_parser (1.21.1)
addressable
csv (3.3.4)
csv (3.3.5)
date (3.4.1)
debug (1.10.0)
irb (~> 1.10)
reline (>= 0.3.8)
derailed_benchmarks (2.2.1)
base64
benchmark-ips (~> 2)
bigdecimal
drb
get_process_mem
heapy (~> 0)
logger
memory_profiler (>= 0, < 2)
mini_histogram (>= 0.3.0)
mutex_m
ostruct
rack (>= 1)
rack-test
rake (> 10, < 14)
ruby-statistics (>= 4.0.1)
ruby2_keywords
thor (>= 0.19, < 2)
docile (1.4.1)
doorkeeper (5.8.2)
railties (>= 5)
dotenv (3.1.8)
dotenv-rails (3.1.8)
dotenv (= 3.1.8)
railties (>= 6.1)
drb (2.2.1)
drb (2.2.3)
erb (5.0.1)
erb_lint (0.9.0)
activesupport
@@ -180,7 +200,7 @@ GEM
logger
faraday-multipart (1.1.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.0)
faraday-net_http (3.4.1)
net-http (>= 0.5.0)
faraday-retry (2.3.1)
faraday (~> 2.0)
@@ -196,9 +216,14 @@ GEM
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
get_process_mem (1.0.0)
bigdecimal (>= 2.0)
ffi (~> 1.0)
globalid (1.2.1)
activesupport (>= 6.1)
hashdiff (1.1.2)
hashdiff (1.2.0)
heapy (0.2.0)
thor
highline (3.1.2)
reline
hotwire-livereload (2.0.0)
@@ -243,8 +268,11 @@ GEM
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.13.0)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
jmespath (1.6.2)
json (2.12.0)
json (2.12.2)
jwt (2.10.1)
base64
language_server-protocol (3.17.0.5)
@@ -292,7 +320,9 @@ GEM
net-smtp
marcel (1.0.4)
matrix (0.4.2)
memory_profiler (1.1.0)
method_source (1.1.0)
mini_histogram (0.3.1)
mini_magick (5.2.0)
benchmark
logger
@@ -302,6 +332,7 @@ GEM
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
net-http (0.6.0)
uri
net-imap (0.5.8)
@@ -340,7 +371,7 @@ GEM
ast (~> 2.4.1)
racc
pg (1.5.9)
plaid (39.0.0)
plaid (41.0.0)
faraday (>= 1.0.1, < 3.0)
faraday-multipart (>= 1.0.1, < 2.0)
platform_agent (1.0.1)
@@ -363,8 +394,10 @@ GEM
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.15)
rack-mini-profiler (3.3.1)
rack (3.1.16)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-mini-profiler (4.0.0)
rack (>= 1.2.0)
rack-session (2.1.1)
base64 (>= 0.1.0)
@@ -387,7 +420,7 @@ GEM
activesupport (= 7.2.2.1)
bundler (>= 1.15.0)
railties (= 7.2.2.1)
rails-dom-testing (2.2.0)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
@@ -409,7 +442,7 @@ GEM
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.2.1)
rake (13.3.0)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
@@ -433,7 +466,7 @@ GEM
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
rubocop (1.75.6)
rubocop (1.76.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -441,10 +474,10 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-ast (>= 1.45.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.44.1)
rubocop-ast (1.45.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.25.0)
@@ -461,19 +494,20 @@ GEM
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-lsp (0.23.20)
ruby-lsp (0.24.1)
language_server-protocol (~> 3.17.0)
prism (>= 1.2, < 2.0)
rbs (>= 3, < 4)
rbs (>= 3, < 5)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.4.3)
ruby-lsp (>= 0.23.18, < 0.24.0)
ruby-lsp-rails (0.4.6)
ruby-lsp (>= 0.24.0, < 0.25.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)
ruby-statistics (4.1.0)
ruby-vips (2.2.4)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
@@ -482,22 +516,22 @@ GEM
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
securerandom (0.4.1)
selenium-webdriver (4.32.0)
selenium-webdriver (4.33.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.24.0)
sentry-rails (5.25.0)
railties (>= 5.0)
sentry-ruby (~> 5.24.0)
sentry-ruby (5.24.0)
sentry-ruby (~> 5.25.0)
sentry-ruby (5.25.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.24.0)
sentry-ruby (~> 5.24.0)
sentry-sidekiq (5.25.0)
sentry-ruby (~> 5.25.0)
sidekiq (>= 3.0)
sidekiq (8.0.3)
sidekiq (8.0.4)
connection_pool (>= 2.5.0)
json (>= 2.9.0)
logger (>= 1.6.2)
@@ -517,26 +551,27 @@ GEM
skylight (6.0.4)
activesupport (>= 5.2.0)
smart_properties (1.17.0)
sorbet-runtime (0.5.12117)
sorbet-runtime (0.5.12163)
stackprof (0.2.27)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.7)
stripe (15.1.0)
stripe (15.2.1)
tailwindcss-rails (4.2.3)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.1.7)
tailwindcss-ruby (4.1.7-aarch64-linux-gnu)
tailwindcss-ruby (4.1.7-aarch64-linux-musl)
tailwindcss-ruby (4.1.7-arm64-darwin)
tailwindcss-ruby (4.1.7-x86_64-darwin)
tailwindcss-ruby (4.1.7-x86_64-linux-gnu)
tailwindcss-ruby (4.1.7-x86_64-linux-musl)
tailwindcss-ruby (4.1.8)
tailwindcss-ruby (4.1.8-aarch64-linux-gnu)
tailwindcss-ruby (4.1.8-aarch64-linux-musl)
tailwindcss-ruby (4.1.8-arm64-darwin)
tailwindcss-ruby (4.1.8-x86_64-darwin)
tailwindcss-ruby (4.1.8-x86_64-linux-gnu)
tailwindcss-ruby (4.1.8-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.13)
turbo-rails (2.0.16)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
@@ -549,10 +584,10 @@ GEM
useragent (0.16.11)
vcr (6.3.1)
base64
vernier (1.7.1)
view_component (3.22.0)
vernier (1.8.0)
view_component (3.23.2)
activesupport (>= 5.2.0, < 8.1)
concurrent-ruby (= 1.3.4)
concurrent-ruby (~> 1)
method_source (~> 1.0)
web-console (4.2.1)
actionview (>= 6.0.0)
@@ -564,7 +599,7 @@ GEM
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11)
websocket-driver (0.7.7)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -596,6 +631,8 @@ DEPENDENCIES
climate_control
csv
debug
derailed_benchmarks
doorkeeper
dotenv-rails
erb_lint
faker
@@ -610,10 +647,11 @@ DEPENDENCIES
importmap-rails
inline_svg
intercom-rails
jbuilder
jwt
letter_opener
logtail-rails
lookbook (>= 2.3.7)
lookbook (= 2.3.9)
lucide-rails!
mocha
octokit
@@ -623,6 +661,7 @@ DEPENDENCIES
plaid
propshaft
puma (>= 5.0)
rack-attack (~> 6.6)
rack-mini-profiler
rails (~> 7.2.2)
rails-settings-cached
@@ -641,6 +680,7 @@ DEPENDENCIES
sidekiq-cron
simplecov
skylight
stackprof
stimulus-rails
stripe
tailwindcss-rails

View File

@@ -38,6 +38,14 @@ Once you've done that, please visit
our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md)
to get started!
### Performance Issues
With data-heavy apps, inevitably, there are performance issues. We've set up a public dashboard showing the problematic requests, along with the stacktraces to help debug them.
Any contributions that help improve performance are very much welcome.
https://oss.skylight.io/app/applications/XDpPIXEX52oi/recent/6h/endpoints
## Local Development Setup
**If you are trying to _self-host_ the Maybe app, stop here. You
@@ -59,7 +67,7 @@ bin/setup
bin/dev
# Optionally, load demo data
rake demo_data:reset
rake demo_data:default
```
And visit http://localhost:3000 to see the app. You can use the following

View File

@@ -39,7 +39,7 @@
.combobox {
.hw-combobox__main__wrapper,
.hw-combobox__input {
@apply w-full;
@apply bg-container text-primary w-full;
}
.hw-combobox__main__wrapper {
@@ -53,6 +53,10 @@
.hw-combobox__label {
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
}
.hw-combobox__option {
@apply bg-container hover:bg-container-hover;
}
.hw_combobox__pagination__wrapper {
@apply h-px;

View File

@@ -2,21 +2,24 @@ class AccountableSparklinesController < ApplicationController
def show
@accountable = Accountable.from_type(params[:accountable_type]&.classify)
@series = Rails.cache.fetch(cache_key) do
account_ids = family.accounts.active.where(accountable_type: @accountable.name).pluck(:id)
etag_key = cache_key
builder = Balance::ChartSeriesBuilder.new(
account_ids: account_ids,
currency: family.currency,
period: Period.last_30_days,
favorable_direction: @accountable.favorable_direction,
interval: "1 day"
)
# Use HTTP conditional GET so the client receives 304 Not Modified when possible.
if stale?(etag: etag_key, last_modified: family.latest_sync_completed_at)
@series = Rails.cache.fetch(etag_key, expires_in: 24.hours) do
builder = Balance::ChartSeriesBuilder.new(
account_ids: account_ids,
currency: family.currency,
period: Period.last_30_days,
favorable_direction: @accountable.favorable_direction,
interval: "1 day"
)
builder.balance_series
builder.balance_series
end
render layout: false
end
render layout: false
end
private
@@ -24,7 +27,15 @@ class AccountableSparklinesController < ApplicationController
Current.family
end
def accountable
Accountable.from_type(params[:accountable_type]&.classify)
end
def account_ids
family.accounts.active.where(accountable_type: accountable.name).pluck(:id)
end
def cache_key
family.build_cache_key("#{@accountable.name}_sparkline")
family.build_cache_key("#{@accountable.name}_sparkline", invalidate_on_data_updates: true)
end
end

View File

@@ -23,7 +23,14 @@ class AccountsController < ApplicationController
end
def sparkline
render layout: false
etag_key = @account.family.build_cache_key("#{@account.id}_sparkline", invalidate_on_data_updates: true)
# Short-circuit with 304 Not Modified when the client already has the latest version.
# We defer the expensive series computation until we know the content is stale.
if stale?(etag: etag_key, last_modified: @account.family.latest_sync_completed_at)
@sparkline_series = @account.sparkline_series
render layout: false
end
end
private

View File

@@ -0,0 +1,59 @@
# frozen_string_literal: true
class Api::V1::AccountsController < Api::V1::BaseController
include Pagy::Backend
# Ensure proper scope authorization for read access
before_action :ensure_read_scope
def index
# Test with Pagy pagination
family = current_resource_owner.family
accounts_query = family.accounts.active.alphabetically
# Handle pagination with Pagy
@pagy, @accounts = pagy(
accounts_query,
page: safe_page_param,
limit: safe_per_page_param
)
@per_page = safe_per_page_param
# Rails will automatically use app/views/api/v1/accounts/index.json.jbuilder
render :index
rescue => e
Rails.logger.error "AccountsController error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
}, status: :internal_server_error
end
private
def ensure_read_scope
authorize_scope!(:read)
end
def safe_page_param
page = params[:page].to_i
page > 0 ? page : 1
end
def safe_per_page_param
per_page = params[:per_page].to_i
# Default to 25, max 100
case per_page
when 1..100
per_page
else
25
end
end
end

View File

@@ -0,0 +1,210 @@
module Api
module V1
class AuthController < BaseController
include Invitable
skip_before_action :authenticate_request!
skip_before_action :check_api_key_rate_limit
skip_before_action :log_api_access
def signup
# Check if invite code is required
if invite_code_required? && params[:invite_code].blank?
render json: { error: "Invite code is required" }, status: :forbidden
return
end
# Validate invite code if provided
if params[:invite_code].present? && !InviteCode.exists?(token: params[:invite_code]&.downcase)
render json: { error: "Invalid invite code" }, status: :forbidden
return
end
# Validate password
password_errors = validate_password(params[:user][:password])
if password_errors.any?
render json: { errors: password_errors }, status: :unprocessable_entity
return
end
# Validate device info
unless valid_device_info?
render json: { error: "Device information is required" }, status: :bad_request
return
end
user = User.new(user_signup_params)
# Create family for new user
family = Family.new
user.family = family
user.role = :admin
if user.save
# Claim invite code if provided
InviteCode.claim!(params[:invite_code]) if params[:invite_code].present?
# Create device and OAuth token
device = create_or_update_device(user)
token_response = create_oauth_token_for_device(user, device)
render json: token_response.merge(
user: {
id: user.id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name
}
), status: :created
else
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
end
end
def login
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
# Check MFA if enabled
if user.otp_required?
unless params[:otp_code].present? && user.verify_otp?(params[:otp_code])
render json: {
error: "Two-factor authentication required",
mfa_required: true
}, status: :unauthorized
return
end
end
# Validate device info
unless valid_device_info?
render json: { error: "Device information is required" }, status: :bad_request
return
end
# Create device and OAuth token
device = create_or_update_device(user)
token_response = create_oauth_token_for_device(user, device)
render json: token_response.merge(
user: {
id: user.id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name
}
)
else
render json: { error: "Invalid email or password" }, status: :unauthorized
end
end
def refresh
# Find the refresh token
refresh_token = params[:refresh_token]
unless refresh_token.present?
render json: { error: "Refresh token is required" }, status: :bad_request
return
end
# Find the access token associated with this refresh token
access_token = Doorkeeper::AccessToken.by_refresh_token(refresh_token)
if access_token.nil? || access_token.revoked?
render json: { error: "Invalid refresh token" }, status: :unauthorized
return
end
# Create new access token
new_token = Doorkeeper::AccessToken.create!(
application: access_token.application,
resource_owner_id: access_token.resource_owner_id,
expires_in: 30.days.to_i,
scopes: access_token.scopes,
use_refresh_token: true
)
# Revoke old access token
access_token.revoke
# Update device last seen
user = User.find(access_token.resource_owner_id)
device = user.mobile_devices.find_by(device_id: params[:device][:device_id])
device&.update_last_seen!
render json: {
access_token: new_token.plaintext_token,
refresh_token: new_token.plaintext_refresh_token,
token_type: "Bearer",
expires_in: new_token.expires_in,
created_at: new_token.created_at.to_i
}
end
private
def user_signup_params
params.require(:user).permit(:email, :password, :first_name, :last_name)
end
def validate_password(password)
errors = []
if password.blank?
errors << "Password can't be blank"
return errors
end
errors << "Password must be at least 8 characters" if password.length < 8
errors << "Password must include both uppercase and lowercase letters" unless password.match?(/[A-Z]/) && password.match?(/[a-z]/)
errors << "Password must include at least one number" unless password.match?(/\d/)
errors << "Password must include at least one special character" unless password.match?(/[!@#$%^&*(),.?":{}|<>]/)
errors
end
def valid_device_info?
device = params[:device]
return false if device.nil?
required_fields = %w[device_id device_name device_type os_version app_version]
required_fields.all? { |field| device[field].present? }
end
def create_or_update_device(user)
# Handle both string and symbol keys
device_data = params[:device].permit(:device_id, :device_name, :device_type, :os_version, :app_version)
device = user.mobile_devices.find_or_initialize_by(device_id: device_data[:device_id])
device.update!(device_data.merge(last_seen_at: Time.current))
device
end
def create_oauth_token_for_device(user, device)
# Create OAuth application for this device if needed
oauth_app = device.create_oauth_application!
# Revoke any existing tokens for this device
device.revoke_all_tokens!
# Create new access token with 30-day expiration
access_token = Doorkeeper::AccessToken.create!(
application: oauth_app,
resource_owner_id: user.id,
expires_in: 30.days.to_i,
scopes: "read_write",
use_refresh_token: true
)
{
access_token: access_token.plaintext_token,
refresh_token: access_token.plaintext_refresh_token,
token_type: "Bearer",
expires_in: access_token.expires_in,
created_at: access_token.created_at.to_i
}
end
end
end
end

View File

@@ -0,0 +1,279 @@
# frozen_string_literal: true
class Api::V1::BaseController < ApplicationController
include Doorkeeper::Rails::Helpers
# Skip regular session-based authentication for API
skip_authentication
# Skip CSRF protection for API endpoints
skip_before_action :verify_authenticity_token
# Skip onboarding requirements for API endpoints
skip_before_action :require_onboarding_and_upgrade
# Force JSON format for all API requests
before_action :force_json_format
# Use our custom authentication that supports both OAuth and API keys
before_action :authenticate_request!
before_action :check_api_key_rate_limit
before_action :log_api_access
# Override Doorkeeper's default behavior to return JSON instead of redirecting
def doorkeeper_unauthorized_render_options(error: nil)
{ json: { error: "unauthorized", message: "Access token is invalid, expired, or missing" } }
end
# Error handling for common API errors
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
rescue_from Doorkeeper::Errors::DoorkeeperError, with: :handle_unauthorized
rescue_from ActionController::ParameterMissing, with: :handle_bad_request
private
# Force JSON format for all API requests
def force_json_format
request.format = :json
end
# Authenticate using either OAuth or API key
def authenticate_request!
return if authenticate_oauth
return if authenticate_api_key
render_unauthorized unless performed?
end
# Try OAuth authentication first
def authenticate_oauth
return false unless request.headers["Authorization"].present?
# Manually verify the token (bypassing doorkeeper_authorize! which had scope issues)
token_string = request.authorization&.split(" ")&.last
access_token = Doorkeeper::AccessToken.by_token(token_string)
# Check token validity and scope (read_write includes read access)
has_sufficient_scope = access_token&.scopes&.include?("read") || access_token&.scopes&.include?("read_write")
unless access_token && !access_token.expired? && has_sufficient_scope
render_json({ error: "unauthorized", message: "Access token is invalid, expired, or missing required scope" }, status: :unauthorized)
return false
end
# Set the doorkeeper_token for compatibility
@_doorkeeper_token = access_token
if doorkeeper_token&.resource_owner_id
@current_user = User.find_by(id: doorkeeper_token.resource_owner_id)
# If user doesn't exist, the token is invalid (user was deleted)
unless @current_user
Rails.logger.warn "API OAuth Token Invalid: Access token resource_owner_id #{doorkeeper_token.resource_owner_id} does not exist"
render_json({ error: "unauthorized", message: "Access token is invalid - user not found" }, status: :unauthorized)
return false
end
else
Rails.logger.warn "API OAuth Token Invalid: Access token missing resource_owner_id"
render_json({ error: "unauthorized", message: "Access token is invalid - missing resource owner" }, status: :unauthorized)
return false
end
@authentication_method = :oauth
setup_current_context_for_api
true
rescue Doorkeeper::Errors::DoorkeeperError => e
Rails.logger.warn "API OAuth Error: #{e.message}"
false
end
# Try API key authentication
def authenticate_api_key
api_key_value = request.headers["X-Api-Key"]
return false unless api_key_value
@api_key = ApiKey.find_by_value(api_key_value)
return false unless @api_key && @api_key.active?
@current_user = @api_key.user
@api_key.update_last_used!
@authentication_method = :api_key
@rate_limiter = ApiRateLimiter.new(@api_key)
setup_current_context_for_api
true
end
# Check rate limits for API key authentication
def check_api_key_rate_limit
return unless @authentication_method == :api_key && @rate_limiter
if @rate_limiter.rate_limit_exceeded?
usage_info = @rate_limiter.usage_info
render_rate_limit_exceeded(usage_info)
return false
end
# Increment request count for successful API key requests
@rate_limiter.increment_request_count!
# Add rate limit headers to response
add_rate_limit_headers(@rate_limiter.usage_info)
end
# Render rate limit exceeded response
def render_rate_limit_exceeded(usage_info)
response.headers["X-RateLimit-Limit"] = usage_info[:rate_limit].to_s
response.headers["X-RateLimit-Remaining"] = "0"
response.headers["X-RateLimit-Reset"] = usage_info[:reset_time].to_s
response.headers["Retry-After"] = usage_info[:reset_time].to_s
Rails.logger.warn "API Rate Limit Exceeded: API Key #{@api_key.name} (User: #{@current_user.email}) - #{usage_info[:current_count]}/#{usage_info[:rate_limit]} requests"
render_json({
error: "rate_limit_exceeded",
message: "Rate limit exceeded. Try again in #{usage_info[:reset_time]} seconds.",
details: {
limit: usage_info[:rate_limit],
current: usage_info[:current_count],
reset_in_seconds: usage_info[:reset_time]
}
}, status: :too_many_requests)
end
# Add rate limit headers to successful responses
def add_rate_limit_headers(usage_info)
response.headers["X-RateLimit-Limit"] = usage_info[:rate_limit].to_s
response.headers["X-RateLimit-Remaining"] = usage_info[:remaining].to_s
response.headers["X-RateLimit-Reset"] = usage_info[:reset_time].to_s
end
# Render unauthorized response
def render_unauthorized
render_json({ error: "unauthorized", message: "Access token or API key is invalid, expired, or missing" }, status: :unauthorized)
end
# Returns the user that owns the access token or API key
def current_resource_owner
@current_user
end
# Get current scopes from either authentication method
def current_scopes
case @authentication_method
when :oauth
doorkeeper_token&.scopes&.to_a || []
when :api_key
@api_key&.scopes || []
else
[]
end
end
# Check if the current authentication has the required scope
# Implements hierarchical scope checking where read_write includes read access
def authorize_scope!(required_scope)
scopes = current_scopes
case required_scope.to_s
when "read"
# Read access requires either "read" or "read_write" scope
has_access = scopes.include?("read") || scopes.include?("read_write")
when "write"
# Write access requires "read_write" scope
has_access = scopes.include?("read_write")
else
# For any other scope, check exact match (backward compatibility)
has_access = scopes.include?(required_scope.to_s)
end
unless has_access
Rails.logger.warn "API Insufficient Scope: User #{current_resource_owner&.email} attempted to access #{required_scope} but only has #{scopes}"
render_json({ error: "insufficient_scope", message: "This action requires the '#{required_scope}' scope" }, status: :forbidden)
return false
end
true
end
# Consistent JSON response method
def render_json(data, status: :ok)
render json: data, status: status
end
# Error handlers
def handle_not_found(exception)
Rails.logger.warn "API Record Not Found: #{exception.message}"
render_json({ error: "record_not_found", message: "The requested resource was not found" }, status: :not_found)
end
def handle_unauthorized(exception)
Rails.logger.warn "API Unauthorized: #{exception.message}"
render_json({ error: "unauthorized", message: "Access token is invalid or expired" }, status: :unauthorized)
end
def handle_bad_request(exception)
Rails.logger.warn "API Bad Request: #{exception.message}"
render_json({ error: "bad_request", message: "Required parameters are missing or invalid" }, status: :bad_request)
end
# Log API access for monitoring and debugging
def log_api_access
return unless current_resource_owner
auth_info = case @authentication_method
when :oauth
"OAuth Token"
when :api_key
"API Key: #{@api_key.name}"
else
"Unknown"
end
Rails.logger.info "API Request: #{request.method} #{request.path} - User: #{current_resource_owner.email} (Family: #{current_resource_owner.family_id}) - Auth: #{auth_info}"
end
# Family-based access control helper (to be used by subcontrollers)
def ensure_current_family_access(resource)
return unless resource.respond_to?(:family_id)
unless resource.family_id == current_resource_owner.family_id
Rails.logger.warn "API Forbidden: User #{current_resource_owner.email} attempted to access resource from family #{resource.family_id}"
render_json({ error: "forbidden", message: "Access denied to this resource" }, status: :forbidden)
return false
end
true
end
# Manual doorkeeper_token accessor for compatibility with manual token verification
def doorkeeper_token
@_doorkeeper_token
end
# Set up Current context for API requests since we don't use session-based auth
def setup_current_context_for_api
# For API requests, we need to create a minimal session-like object
# or find/create an actual session for this user to make Current.user work
if @current_user
# Try to find an existing session for this user, or create a temporary one
session = @current_user.sessions.first
if session
Current.session = session
else
# Create a temporary session for this API request
# This won't be persisted but will allow Current.user to work
session = @current_user.sessions.build(
user_agent: request.user_agent,
ip_address: request.ip
)
Current.session = session
end
end
end
# Check if AI features are enabled for the current user
def require_ai_enabled
unless current_resource_owner&.ai_enabled?
render_json({ error: "feature_disabled", message: "AI features are not enabled for this user" }, status: :forbidden)
end
end
end

View File

@@ -0,0 +1,84 @@
# frozen_string_literal: true
class Api::V1::ChatsController < Api::V1::BaseController
include Pagy::Backend
before_action :require_ai_enabled
before_action :ensure_read_scope, only: [ :index, :show ]
before_action :ensure_write_scope, only: [ :create, :update, :destroy ]
before_action :set_chat, only: [ :show, :update, :destroy ]
def index
@pagy, @chats = pagy(Current.user.chats.ordered, items: 20)
end
def show
return unless @chat
@pagy, @messages = pagy(@chat.messages.ordered, items: 50)
end
def create
@chat = Current.user.chats.build(title: chat_params[:title])
if @chat.save
if chat_params[:message].present?
@message = @chat.messages.build(
content: chat_params[:message],
type: "UserMessage",
ai_model: chat_params[:model] || "gpt-4"
)
if @message.save
AssistantResponseJob.perform_later(@message)
render :show, status: :created
else
@chat.destroy
render json: { error: "Failed to create initial message", details: @message.errors.full_messages }, status: :unprocessable_entity
end
else
render :show, status: :created
end
else
render json: { error: "Failed to create chat", details: @chat.errors.full_messages }, status: :unprocessable_entity
end
end
def update
return unless @chat
if @chat.update(update_chat_params)
render :show
else
render json: { error: "Failed to update chat", details: @chat.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
return unless @chat
@chat.destroy
head :no_content
end
private
def ensure_read_scope
authorize_scope!(:read)
end
def ensure_write_scope
authorize_scope!(:write)
end
def set_chat
@chat = Current.user.chats.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Chat not found" }, status: :not_found
end
def chat_params
params.permit(:title, :message, :model)
end
def update_chat_params
params.permit(:title)
end
end

View File

@@ -0,0 +1,55 @@
# frozen_string_literal: true
class Api::V1::MessagesController < Api::V1::BaseController
before_action :require_ai_enabled
before_action :ensure_write_scope, only: [ :create, :retry ]
before_action :set_chat
def create
@message = @chat.messages.build(
content: message_params[:content],
type: "UserMessage",
ai_model: message_params[:model] || "gpt-4"
)
if @message.save
AssistantResponseJob.perform_later(@message)
render :show, status: :created
else
render json: { error: "Failed to create message", details: @message.errors.full_messages }, status: :unprocessable_entity
end
end
def retry
last_message = @chat.messages.ordered.last
if last_message&.type == "AssistantMessage"
new_message = @chat.messages.create!(
type: "AssistantMessage",
content: "",
ai_model: last_message.ai_model
)
AssistantResponseJob.perform_later(new_message)
render json: { message: "Retry initiated", message_id: new_message.id }, status: :accepted
else
render json: { error: "No assistant message to retry" }, status: :unprocessable_entity
end
end
private
def ensure_write_scope
authorize_scope!(:write)
end
def set_chat
@chat = Current.user.chats.find(params[:chat_id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Chat not found" }, status: :not_found
end
def message_params
params.permit(:content, :model)
end
end

View File

@@ -0,0 +1,47 @@
# frozen_string_literal: true
# Test controller for API V1 Base Controller functionality
# This controller is only used for testing the base controller behavior
class Api::V1::TestController < Api::V1::BaseController
def index
render_json({ message: "test_success", user: current_resource_owner&.email })
end
def not_found
# Trigger RecordNotFound error for testing error handling
raise ActiveRecord::RecordNotFound, "Test record not found"
end
def family_access
# Test family-based access control
# Create a mock resource that belongs to a different family
mock_resource = OpenStruct.new(family_id: 999) # Different family ID
# Check family access - if it returns false, it already rendered the error
if ensure_current_family_access(mock_resource)
# If we get here, access was allowed
render_json({ family_id: current_resource_owner.family_id })
end
end
def scope_required
# Test scope authorization - require write scope
return unless authorize_scope!("write")
render_json({
message: "scope_authorized",
scopes: current_scopes,
required_scope: "write"
})
end
def multiple_scopes_required
# Test read scope requirement
return unless authorize_scope!("read")
render_json({
message: "read_scope_authorized",
scopes: current_scopes
})
end
end

View File

@@ -0,0 +1,327 @@
# frozen_string_literal: true
class Api::V1::TransactionsController < Api::V1::BaseController
include Pagy::Backend
# Ensure proper scope authorization for read vs write access
before_action :ensure_read_scope, only: [ :index, :show ]
before_action :ensure_write_scope, only: [ :create, :update, :destroy ]
before_action :set_transaction, only: [ :show, :update, :destroy ]
def index
family = current_resource_owner.family
transactions_query = family.transactions.active
# Apply filters
transactions_query = apply_filters(transactions_query)
# Apply search
transactions_query = apply_search(transactions_query) if params[:search].present?
# Include necessary associations for efficient queries
transactions_query = transactions_query.includes(
{ entry: :account },
:category, :merchant, :tags,
transfer_as_outflow: { inflow_transaction: { entry: :account } },
transfer_as_inflow: { outflow_transaction: { entry: :account } }
).reverse_chronological
# Handle pagination with Pagy
@pagy, @transactions = pagy(
transactions_query,
page: safe_page_param,
limit: safe_per_page_param
)
# Make per_page available to the template
@per_page = safe_per_page_param
# Rails will automatically use app/views/api/v1/transactions/index.json.jbuilder
render :index
rescue => e
Rails.logger.error "TransactionsController#index error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
}, status: :internal_server_error
end
def show
# Rails will automatically use app/views/api/v1/transactions/show.json.jbuilder
render :show
rescue => e
Rails.logger.error "TransactionsController#show error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
}, status: :internal_server_error
end
def create
family = current_resource_owner.family
# Validate account_id is present
unless transaction_params[:account_id].present?
render json: {
error: "validation_failed",
message: "Account ID is required",
errors: [ "Account ID is required" ]
}, status: :unprocessable_entity
return
end
account = family.accounts.find(transaction_params[:account_id])
@entry = account.entries.new(entry_params_for_create)
if @entry.save
@entry.sync_account_later
@entry.lock_saved_attributes!
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
@transaction = @entry.transaction
render :show, status: :created
else
render json: {
error: "validation_failed",
message: "Transaction could not be created",
errors: @entry.errors.full_messages
}, status: :unprocessable_entity
end
rescue => e
Rails.logger.error "TransactionsController#create error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
}, status: :internal_server_error
end
def update
if @entry.update(entry_params_for_update)
@entry.sync_account_later
@entry.lock_saved_attributes!
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
@transaction = @entry.transaction
render :show
else
render json: {
error: "validation_failed",
message: "Transaction could not be updated",
errors: @entry.errors.full_messages
}, status: :unprocessable_entity
end
rescue => e
Rails.logger.error "TransactionsController#update error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
}, status: :internal_server_error
end
def destroy
@entry.destroy!
@entry.sync_account_later
render json: {
message: "Transaction deleted successfully"
}, status: :ok
rescue => e
Rails.logger.error "TransactionsController#destroy error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
}, status: :internal_server_error
end
private
def set_transaction
family = current_resource_owner.family
@transaction = family.transactions.find(params[:id])
@entry = @transaction.entry
rescue ActiveRecord::RecordNotFound
render json: {
error: "not_found",
message: "Transaction not found"
}, status: :not_found
end
def ensure_read_scope
authorize_scope!(:read)
end
def ensure_write_scope
authorize_scope!(:write)
end
def apply_filters(query)
# Account filtering
if params[:account_id].present?
query = query.joins(:entry).where(entries: { account_id: params[:account_id] })
end
if params[:account_ids].present?
account_ids = Array(params[:account_ids])
query = query.joins(:entry).where(entries: { account_id: account_ids })
end
# Category filtering
if params[:category_id].present?
query = query.where(category_id: params[:category_id])
end
if params[:category_ids].present?
category_ids = Array(params[:category_ids])
query = query.where(category_id: category_ids)
end
# Merchant filtering
if params[:merchant_id].present?
query = query.where(merchant_id: params[:merchant_id])
end
if params[:merchant_ids].present?
merchant_ids = Array(params[:merchant_ids])
query = query.where(merchant_id: merchant_ids)
end
# Date range filtering
if params[:start_date].present?
query = query.joins(:entry).where("entries.date >= ?", Date.parse(params[:start_date]))
end
if params[:end_date].present?
query = query.joins(:entry).where("entries.date <= ?", Date.parse(params[:end_date]))
end
# Amount filtering
if params[:min_amount].present?
min_amount = params[:min_amount].to_f
query = query.joins(:entry).where("entries.amount >= ?", min_amount)
end
if params[:max_amount].present?
max_amount = params[:max_amount].to_f
query = query.joins(:entry).where("entries.amount <= ?", max_amount)
end
# Tag filtering
if params[:tag_ids].present?
tag_ids = Array(params[:tag_ids])
query = query.joins(:tags).where(tags: { id: tag_ids })
end
# Transaction type filtering (income/expense)
if params[:type].present?
case params[:type].downcase
when "income"
query = query.joins(:entry).where("entries.amount < 0")
when "expense"
query = query.joins(:entry).where("entries.amount > 0")
end
end
query
end
def apply_search(query)
search_term = "%#{params[:search]}%"
query.joins(:entry)
.left_joins(:merchant)
.where(
"entries.name ILIKE ? OR entries.notes ILIKE ? OR merchants.name ILIKE ?",
search_term, search_term, search_term
)
end
def transaction_params
params.require(:transaction).permit(
:account_id, :date, :amount, :name, :description, :notes, :currency,
:category_id, :merchant_id, :nature, tag_ids: []
)
end
def entry_params_for_create
entry_params = {
name: transaction_params[:name] || transaction_params[:description],
date: transaction_params[:date],
amount: calculate_signed_amount,
currency: transaction_params[:currency] || current_resource_owner.family.currency,
notes: transaction_params[:notes],
entryable_type: "Transaction",
entryable_attributes: {
category_id: transaction_params[:category_id],
merchant_id: transaction_params[:merchant_id],
tag_ids: transaction_params[:tag_ids] || []
}
}
entry_params.compact
end
def entry_params_for_update
entry_params = {
name: transaction_params[:name] || transaction_params[:description],
date: transaction_params[:date],
notes: transaction_params[:notes],
entryable_attributes: {
id: @entry.entryable_id,
category_id: transaction_params[:category_id],
merchant_id: transaction_params[:merchant_id],
tag_ids: transaction_params[:tag_ids]
}.compact_blank
}
# Only update amount if provided
if transaction_params[:amount].present?
entry_params[:amount] = calculate_signed_amount
end
entry_params.compact
end
def calculate_signed_amount
amount = transaction_params[:amount].to_f
nature = transaction_params[:nature]
case nature&.downcase
when "income", "inflow"
-amount.abs # Income is negative
when "expense", "outflow"
amount.abs # Expense is positive
else
amount # Use as provided
end
end
def safe_page_param
page = params[:page].to_i
page > 0 ? page : 1
end
def safe_per_page_param
per_page = params[:per_page].to_i
case per_page
when 1..100
per_page
else
25 # Default
end
end
end

View File

@@ -0,0 +1,38 @@
class Api::V1::UsageController < Api::V1::BaseController
# GET /api/v1/usage
def show
return unless authorize_scope!(:read)
case @authentication_method
when :api_key
usage_info = @rate_limiter.usage_info
render_json({
api_key: {
name: @api_key.name,
scopes: @api_key.scopes,
last_used_at: @api_key.last_used_at,
created_at: @api_key.created_at
},
rate_limit: {
tier: usage_info[:tier],
limit: usage_info[:rate_limit],
current_count: usage_info[:current_count],
remaining: usage_info[:remaining],
reset_in_seconds: usage_info[:reset_time],
reset_at: Time.current + usage_info[:reset_time].seconds
}
})
when :oauth
# For OAuth, we don't track detailed usage yet, but we can return basic info
render_json({
authentication_method: "oauth",
message: "Detailed usage tracking is available for API key authentication"
})
else
render_json({
error: "invalid_authentication_method",
message: "Unable to determine usage information"
}, status: :bad_request)
end
end
end

View File

@@ -1,9 +1,9 @@
module AutoSync
extend ActiveSupport::Concern
included do
before_action :sync_family, if: :family_needs_auto_sync?
end
# included do
# before_action :sync_family, if: :family_needs_auto_sync?
# end
private
def sync_family

View File

@@ -25,6 +25,7 @@ module Onboardable
return false if path.starts_with?("/subscription")
return false if path.starts_with?("/onboarding")
return false if path.starts_with?("/users")
return false if path.starts_with?("/api") # Exclude API endpoints from onboarding redirects
[
new_registration_path,

View File

@@ -3,6 +3,8 @@ module SelfHostable
included do
helper_method :self_hosted?, :self_hosted_first_login?
prepend_before_action :verify_self_host_config
end
private
@@ -13,4 +15,29 @@ module SelfHostable
def self_hosted_first_login?
self_hosted? && User.count.zero?
end
def verify_self_host_config
return unless self_hosted?
# Special handling for Redis configuration error page
if controller_name == "pages" && action_name == "redis_configuration_error"
# If Redis is now working, redirect to home
if redis_connected?
redirect_to root_path, notice: "Redis is now configured properly! You can now setup your Maybe application."
end
return
end
unless redis_connected?
redirect_to redis_configuration_error_path
end
end
def redis_connected?
Redis.new.ping
true
rescue Redis::CannotConnectError
false
end
end

View File

@@ -4,19 +4,19 @@ class FamilyMerchantsController < ApplicationController
def index
@breadcrumbs = [ [ "Home", root_path ], [ "Merchants", nil ] ]
@merchants = Current.family.merchants.alphabetically
@family_merchants = Current.family.merchants.alphabetically
render layout: "settings"
end
def new
@merchant = FamilyMerchant.new(family: Current.family)
@family_merchant = FamilyMerchant.new(family: Current.family)
end
def create
@merchant = FamilyMerchant.new(merchant_params.merge(family: Current.family))
@family_merchant = FamilyMerchant.new(merchant_params.merge(family: Current.family))
if @merchant.save
if @family_merchant.save
respond_to do |format|
format.html { redirect_to family_merchants_path, notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
@@ -30,7 +30,7 @@ class FamilyMerchantsController < ApplicationController
end
def update
@merchant.update!(merchant_params)
@family_merchant.update!(merchant_params)
respond_to do |format|
format.html { redirect_to family_merchants_path, notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
@@ -38,14 +38,13 @@ class FamilyMerchantsController < ApplicationController
end
def destroy
@merchant.destroy!
@family_merchant.destroy!
redirect_to family_merchants_path, notice: t(".success")
end
private
def set_merchant
@merchant = Current.family.merchants.find(params[:id])
@family_merchant = Current.family.merchants.find(params[:id])
end
def merchant_params

View File

@@ -1,7 +1,8 @@
class PagesController < ApplicationController
skip_before_action :authenticate_user!, only: %i[early_access]
include Periodable
skip_authentication only: :redis_configuration_error
def dashboard
@balance_sheet = Current.family.balance_sheet
@accounts = Current.family.accounts.active.with_attached_logo
@@ -29,6 +30,17 @@ class PagesController < ApplicationController
def changelog
@release_notes = github_provider.fetch_latest_release_notes
# Fallback if no release notes are available
if @release_notes.nil?
@release_notes = {
avatar: "https://github.com/maybe-finance.png",
username: "maybe-finance",
name: "Release notes unavailable",
published_at: Date.current,
body: "<p>Unable to fetch the latest release notes at this time. Please check back later or visit our <a href='https://github.com/maybe-finance/maybe/releases' target='_blank'>GitHub releases page</a> directly.</p>"
}
end
render layout: "settings"
end
@@ -36,12 +48,8 @@ class PagesController < ApplicationController
render layout: "settings"
end
def early_access
redirect_to root_path if self_hosted?
@invite_codes_count = InviteCode.count
@invite_code = InviteCode.order("RANDOM()").limit(1).first
render layout: false
def redis_configuration_error
render layout: "blank"
end
private

View File

@@ -0,0 +1,61 @@
# frozen_string_literal: true
class Settings::ApiKeysController < ApplicationController
layout "settings"
before_action :set_api_key, only: [ :show, :destroy ]
def show
@current_api_key = @api_key
end
def new
# Allow regeneration by not redirecting if user explicitly wants to create a new key
# Only redirect if user stumbles onto new page without explicit intent
redirect_to settings_api_key_path if Current.user.api_keys.active.exists? && !params[:regenerate]
@api_key = ApiKey.new
end
def create
@plain_key = ApiKey.generate_secure_key
@api_key = Current.user.api_keys.build(api_key_params)
@api_key.key = @plain_key
# Temporarily revoke existing keys for validation to pass
existing_keys = Current.user.api_keys.active
existing_keys.each { |key| key.update_column(:revoked_at, Time.current) }
if @api_key.save
flash[:notice] = "Your API key has been created successfully"
redirect_to settings_api_key_path
else
# Restore existing keys if new key creation failed
existing_keys.each { |key| key.update_column(:revoked_at, nil) }
render :new, status: :unprocessable_entity
end
end
def destroy
if @api_key&.revoke!
flash[:notice] = "API key has been revoked successfully"
else
flash[:alert] = "Failed to revoke API key"
end
redirect_to settings_api_key_path
end
private
def set_api_key
@api_key = Current.user.api_keys.active.first
end
def api_key_params
# Convert single scope value to array for storage
permitted_params = params.require(:api_key).permit(:name, :scopes)
if permitted_params[:scopes].present?
permitted_params[:scopes] = [ permitted_params[:scopes] ]
end
permitted_params
end
end

View File

@@ -1,17 +1,27 @@
class TradesController < ApplicationController
include EntryableResource
# Defaults to a buy trade
def new
@account = Current.family.accounts.find_by(id: params[:account_id])
@model = Current.family.entries.new(
account: @account,
currency: @account ? @account.currency : Current.family.currency,
entryable: Trade.new
)
end
# Can create a trade, transaction (e.g. "fees"), or transfer (e.g. "withdrawal")
def create
@entry = build_entry
if @entry.save
@entry.sync_account_later
@account = Current.family.accounts.find(params[:account_id])
@model = Trade::CreateForm.new(create_params.merge(account: @account)).create
if @model.persisted?
flash[:notice] = t("entries.create.success")
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account) }
format.turbo_stream { stream_redirect_back_or_to account_path(@entry.account) }
format.html { redirect_back_or_to account_path(@account) }
format.turbo_stream { stream_redirect_back_or_to account_path(@account) }
end
else
render :new, status: :unprocessable_entity
@@ -41,11 +51,6 @@ class TradesController < ApplicationController
end
private
def build_entry
account = Current.family.accounts.find(params.dig(:entry, :account_id))
TradeBuilder.new(create_entry_params.merge(account: account))
end
def entry_params
params.require(:entry).permit(
:name, :date, :amount, :currency, :excluded, :notes, :nature,
@@ -53,8 +58,8 @@ class TradesController < ApplicationController
)
end
def create_entry_params
params.require(:entry).permit(
def create_params
params.require(:model).permit(
:date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id
)
end

View File

@@ -11,38 +11,21 @@ class TransactionsController < ApplicationController
def index
@q = search_params
transactions_query = Current.family.transactions.active.search(@q)
@search = Transaction::Search.new(Current.family, filters: @q)
set_focused_record(transactions_query, params[:focused_record_id], default_per_page: 50)
base_scope = @search.transactions_scope
.reverse_chronological
.includes(
{ entry: :account },
:category, :merchant, :tags,
:transfer_as_inflow, :transfer_as_outflow
)
@pagy, @transactions = pagy(
transactions_query.includes(
{ entry: :account },
:category, :merchant, :tags,
transfer_as_outflow: { inflow_transaction: { entry: :account } },
transfer_as_inflow: { outflow_transaction: { entry: :account } }
).reverse_chronological,
limit: params[:per_page].presence || default_params[:per_page],
params: ->(params) { params.except(:focused_record_id) }
)
@pagy, @transactions = pagy(base_scope, limit: per_page, params: ->(p) { p.except(:focused_record_id) })
# -------------------------------------------------------------------
# Cache totals
# -------------------------------------------------------------------
# Totals calculation is expensive (heavy SQL with grouping). We cache the
# result keyed by:
# • Family id
# • The family-level cache key that already embeds entries.maximum(:updated_at)
# • A digest of the current search params so each distinct filter set gets
# its own cache entry.
# When any entry is created/updated/deleted, the family cache key changes,
# automatically invalidating all related totals.
params_digest = Digest::MD5.hexdigest(@q.to_json)
cache_key = Current.family.build_cache_key("transactions_totals_#{params_digest}")
@totals = Rails.cache.fetch(cache_key) do
Current.family.income_statement.totals(transactions_scope: transactions_query)
# No performance penalty by default. Only runs queries if the record is set.
if params[:focused_record_id].present?
set_focused_record(base_scope, params[:focused_record_id], default_per_page: per_page)
end
end
@@ -66,6 +49,10 @@ class TransactionsController < ApplicationController
end
updated_params["q"] = q_params.presence
# Add flag to indicate filters were explicitly cleared
updated_params["filter_cleared"] = "1" if updated_params["q"].blank?
Current.session.update!(prev_transaction_page_params: updated_params)
redirect_to transactions_path(updated_params)
@@ -127,6 +114,10 @@ class TransactionsController < ApplicationController
end
private
def per_page
params[:per_page].to_i.positive? ? params[:per_page].to_i : 50
end
def needs_rule_notification?(transaction)
return false if Current.user.rule_prompts_disabled
@@ -142,7 +133,7 @@ class TransactionsController < ApplicationController
def entry_params
entry_params = params.require(:entry).permit(
:name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,
entryable_attributes: [ :id, :category_id, :merchant_id, { tag_ids: [] } ]
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, { tag_ids: [] } ]
)
nature = entry_params.delete(:nature)
@@ -159,7 +150,8 @@ class TransactionsController < ApplicationController
cleaned_params = params.fetch(:q, {})
.permit(
:start_date, :end_date, :search, :amount,
:amount_operator, accounts: [], account_ids: [],
:amount_operator, :active_accounts_only,
accounts: [], account_ids: [],
categories: [], merchants: [], types: [], tags: []
)
.to_h
@@ -167,35 +159,9 @@ class TransactionsController < ApplicationController
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
# -------------------------------------------------------------------
# Performance optimisation
# -------------------------------------------------------------------
# When a user lands on the Transactions page without an explicit date
# filter, the previous behaviour queried *all* historical transactions
# for the family. For large datasets this results in very expensive
# SQL (as shown in Skylight) particularly the aggregation queries
# used for @totals. To keep the UI responsive while still showing a
# sensible period of activity, we fall back to the user's preferred
# default period (stored on User#default_period, defaulting to
# "last_30_days") when **no** date filters have been supplied.
#
# This effectively changes the default view from "all-time" to a
# rolling window, dramatically reducing the rows scanned / grouped in
# Postgres without impacting the UX (the user can always clear the
# filter).
# -------------------------------------------------------------------
if cleaned_params[:start_date].blank? && cleaned_params[:end_date].blank?
period_key = Current.user&.default_period.presence || "last_30_days"
begin
period = Period.from_key(period_key)
cleaned_params[:start_date] = period.start_date
cleaned_params[:end_date] = period.end_date
rescue Period::InvalidKeyError
# Fallback should never happen but keeps things safe.
cleaned_params[:start_date] = 30.days.ago.to_date
cleaned_params[:end_date] = Date.current
end
# Only add default start_date if params are blank AND filters weren't explicitly cleared
if cleaned_params.blank? && params[:filter_cleared].blank?
cleaned_params[:start_date] = 30.days.ago.to_date
end
cleaned_params
@@ -205,9 +171,9 @@ class TransactionsController < ApplicationController
if should_restore_params?
params_to_restore = {}
params_to_restore[:q] = stored_params["q"].presence || default_params[:q]
params_to_restore[:page] = stored_params["page"].presence || default_params[:page]
params_to_restore[:per_page] = stored_params["per_page"].presence || default_params[:per_page]
params_to_restore[:q] = stored_params["q"].presence || {}
params_to_restore[:page] = stored_params["page"].presence || 1
params_to_restore[:per_page] = stored_params["per_page"].presence || 50
redirect_to transactions_path(params_to_restore)
else
@@ -228,12 +194,4 @@ class TransactionsController < ApplicationController
def stored_params
Current.session.prev_transaction_page_params
end
def default_params
{
q: {},
page: 1,
per_page: 50
}
end
end

View File

@@ -8,7 +8,12 @@ class TransferMatchesController < ApplicationController
def create
@transfer = build_transfer
@transfer.save!
Transfer.transaction do
@transfer.save!
@transfer.outflow_transaction.update!(kind: Transfer.kind_for_account(@transfer.outflow_transaction.entry.account))
@transfer.inflow_transaction.update!(kind: "funds_movement")
end
@transfer.sync_account_later
redirect_back_or_to transactions_path, notice: "Transfer created"

View File

@@ -1,5 +1,7 @@
class TransfersController < ApplicationController
before_action :set_transfer, only: %i[destroy show update]
include StreamExtensions
before_action :set_transfer, only: %i[show destroy update]
def new
@transfer = Transfer.new
@@ -10,25 +12,19 @@ class TransfersController < ApplicationController
end
def create
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
@transfer = Transfer.from_accounts(
from_account: from_account,
to_account: to_account,
@transfer = Transfer::Creator.new(
family: Current.family,
source_account_id: transfer_params[:from_account_id],
destination_account_id: transfer_params[:to_account_id],
date: transfer_params[:date],
amount: transfer_params[:amount].to_d
)
if @transfer.save
@transfer.sync_account_later
flash[:notice] = t(".success")
).create
if @transfer.persisted?
success_message = "Transfer created"
respond_to do |format|
format.html { redirect_back_or_to transactions_path }
redirect_target_url = request.referer || transactions_path
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
format.html { redirect_back_or_to transactions_path, notice: success_message }
format.turbo_stream { stream_redirect_back_or_to transactions_path, notice: success_message }
end
else
render :new, status: :unprocessable_entity
@@ -54,9 +50,11 @@ class TransfersController < ApplicationController
private
def set_transfer
@transfer = Transfer.find(params[:id])
raise ActiveRecord::RecordNotFound unless @transfer.belongs_to_family?(Current.family)
# Finds the transfer and ensures the family owns it
@transfer = Transfer
.where(id: params[:id])
.where(inflow_transaction_id: Current.family.transactions.select(:id))
.first
end
def transfer_params

View File

@@ -110,7 +110,13 @@ module ApplicationHelper
private
def calculate_total(item, money_method, negate)
items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? }
# Filter out transfer-type transactions from entries
# Only Entry objects have entryable transactions, Account objects don't
items = item.reject do |i|
i.is_a?(Entry) &&
i.entryable.is_a?(Transaction) &&
i.entryable.transfer?
end
total = items.sum(&money_method)
negate ? -total : total
end

View File

@@ -4,6 +4,7 @@ module SettingsHelper
{ name: I18n.t("settings.settings_nav.preferences_label"), path: :settings_preferences_path },
{ name: I18n.t("settings.settings_nav.security_label"), path: :settings_security_path },
{ name: I18n.t("settings.settings_nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
{ name: "API Key", path: :settings_api_key_path },
{ name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path, condition: :not_self_hosted? },
{ name: I18n.t("settings.settings_nav.accounts_label"), path: :accounts_path },
{ name: I18n.t("settings.settings_nav.imports_label"), path: :imports_path },

View File

@@ -0,0 +1,42 @@
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="turbo-frame-timeout"
export default class extends Controller {
static values = { timeout: { type: Number, default: 10000 } }
connect() {
this.timeoutId = setTimeout(() => {
this.handleTimeout()
}, this.timeoutValue)
// Listen for successful frame loads to clear timeout
this.element.addEventListener("turbo:frame-load", this.clearTimeout.bind(this))
}
disconnect() {
this.clearTimeout()
}
clearTimeout() {
if (this.timeoutId) {
clearTimeout(this.timeoutId)
this.timeoutId = null
}
}
handleTimeout() {
// Replace loading content with error state
this.element.innerHTML = `
<div class="flex items-center justify-end gap-1">
<div class="w-8 h-4 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-warning">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/>
<path d="M12 9v4"/>
<path d="m12 17 .01 0"/>
</svg>
</div>
<p class="font-mono text-right text-xs text-warning">Timeout</p>
</div>
`
}
}

View File

@@ -11,6 +11,8 @@ class ImportMarketDataJob < ApplicationJob
queue_as :scheduled
def perform(opts)
return if Rails.env.development?
opts = opts.symbolize_keys
mode = opts.fetch(:mode, :full)
clear_cache = opts.fetch(:clear_cache, false)

View File

@@ -2,6 +2,8 @@ class SecurityHealthCheckJob < ApplicationJob
queue_as :scheduled
def perform
return if Rails.env.development?
Security::HealthChecker.check_all
end
end

View File

@@ -61,18 +61,6 @@ class Account < ApplicationRecord
end
end
def syncing?
self_syncing = syncs.visible.any?
# Since Plaid Items sync as a "group", if the item is syncing, even if the account
# sync hasn't yet started (i.e. we're still fetching the Plaid data), show it as syncing in UI.
if linked?
plaid_account&.plaid_item&.syncing? || self_syncing
else
self_syncing
end
end
def institution_domain
url_string = plaid_account&.plaid_item&.institution_url
return nil unless url_string.present?

View File

@@ -24,9 +24,9 @@ module Account::Chartable
end
def sparkline_series
cache_key = family.build_cache_key("#{id}_sparkline")
cache_key = family.build_cache_key("#{id}_sparkline", invalidate_on_data_updates: true)
Rails.cache.fetch(cache_key) do
Rails.cache.fetch(cache_key, expires_in: 24.hours) do
balance_series
end
end

View File

@@ -16,13 +16,13 @@ class Account::SyncCompleteEvent
locals: { account: account }
)
# Replace the groups this account belongs to in the sidebar
account_group_ids.each do |id|
# Replace the groups this account belongs to in both desktop and mobile sidebars
sidebar_targets.each do |(tab, mobile_flag)|
account.broadcast_replace_to(
account.family,
target: id,
target: account_group.dom_id(tab: tab, mobile: mobile_flag),
partial: "accounts/accountable_group",
locals: { account_group: account_group, open: true }
locals: { account_group: account_group, open: true, all_tab: tab == :all, mobile: mobile_flag }
)
end
@@ -37,18 +37,18 @@ class Account::SyncCompleteEvent
end
private
# The sidebar will show the account in both its classification tab and the "all" tab,
# so we need to broadcast to both.
def account_group_ids
unless account_group.present?
error = Error.new("Account #{account.id} is not part of an account group")
Rails.logger.warn(error.message)
Sentry.capture_exception(error, level: :warning)
return []
end
# Returns an array of [tab, mobile?] tuples that should receive an update.
# We broadcast to both the classification-specific tab and the "all" tab,
# for desktop (mobile: false) and mobile (mobile: true) variants.
def sidebar_targets
return [] unless account_group.present?
id = account_group.id
[ id, "#{account_group.classification}_#{id}" ]
[
[ account_group.classification.to_sym, false ],
[ :all, false ],
[ account_group.classification.to_sym, true ],
[ :all, true ]
]
end
def account_group

94
app/models/api_key.rb Normal file
View File

@@ -0,0 +1,94 @@
class ApiKey < ApplicationRecord
belongs_to :user
# Use Rails built-in encryption for secure storage
encrypts :display_key, deterministic: true
# Constants
SOURCES = [ "web", "mobile" ].freeze
# Validations
validates :display_key, presence: true, uniqueness: true
validates :name, presence: true
validates :scopes, presence: true
validates :source, presence: true, inclusion: { in: SOURCES }
validate :scopes_not_empty
validate :one_active_key_per_user_per_source, on: :create
# Callbacks
before_validation :set_display_key
# Scopes
scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
# Class methods
def self.find_by_value(plain_key)
return nil unless plain_key
# Find by encrypted display_key (deterministic encryption allows querying)
find_by(display_key: plain_key)&.tap do |api_key|
return api_key if api_key.active?
end
end
def self.generate_secure_key
SecureRandom.hex(32)
end
# Instance methods
def active?
!revoked? && !expired?
end
def revoked?
revoked_at.present?
end
def expired?
expires_at.present? && expires_at < Time.current
end
def key_matches?(plain_key)
display_key == plain_key
end
def revoke!
update!(revoked_at: Time.current)
end
def update_last_used!
update_column(:last_used_at, Time.current)
end
# Get the plain text API key for display (automatically decrypted by Rails)
def plain_key
display_key
end
# Temporarily store the plain key for creation flow
attr_accessor :key
private
def set_display_key
if key.present?
self.display_key = key
end
end
def scopes_not_empty
if scopes.blank? || (scopes.is_a?(Array) && (scopes.empty? || scopes.all?(&:blank?)))
errors.add(:scopes, "must include at least one permission")
elsif scopes.is_a?(Array) && scopes.length > 1
errors.add(:scopes, "can only have one permission level")
elsif scopes.is_a?(Array) && !%w[read read_write].include?(scopes.first)
errors.add(:scopes, "must be either 'read' or 'read_write'")
end
end
def one_active_key_per_user_per_source
if user&.api_keys&.active&.where(source: source)&.where&.not(id: id)&.exists?
errors.add(:user, "can only have one active API key per source (#{source})")
end
end
end

View File

@@ -30,8 +30,7 @@ module Assistant::Configurable
## Your purpose
You help users understand their financial data by answering questions about their accounts,
transactions, income, expenses, net worth, and more.
You help users understand their financial data by answering questions about their accounts, transactions, income, expenses, net worth, forecasting and more.
## Your rules
@@ -66,11 +65,9 @@ module Assistant::Configurable
### 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.).
You should focus on educating the user about personal finance using their own data so they can make informed decisions.
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 tell the user to buy or sell specific financial products or investments.
- Do not make assumptions about the user's financial situation. Use the functions available to get the data you need.
### Function calling rules

View File

@@ -31,11 +31,11 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
monthly_history: historical_data(period)
},
assets: {
current: family.balance_sheet.total_assets_money.format,
current: family.balance_sheet.assets.total_money.format,
monthly_history: historical_data(period, classification: "asset")
},
liabilities: {
current: family.balance_sheet.total_liabilities_money.format,
current: family.balance_sheet.liabilities.total_money.format,
monthly_history: historical_data(period, classification: "liability")
},
insights: insights_data
@@ -65,8 +65,8 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
end
def insights_data
assets = family.balance_sheet.total_assets
liabilities = family.balance_sheet.total_liabilities
assets = family.balance_sheet.assets.total
liabilities = family.balance_sheet.liabilities.total
ratio = liabilities.zero? ? 0 : (liabilities / assets.to_f)
{

View File

@@ -163,7 +163,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
category: txn.category&.name,
merchant: txn.merchant&.name,
tags: txn.tags.map(&:name),
is_transfer: txn.transfer.present?
is_transfer: txn.transfer?
}
end

View File

@@ -9,14 +9,23 @@ class Balance::ChartSeriesBuilder
def balance_series
build_series_for(:balance)
rescue => e
Rails.logger.error "Balance series error: #{e.message} for accounts #{@account_ids}"
raise
end
def cash_balance_series
build_series_for(:cash_balance)
rescue => e
Rails.logger.error "Cash balance series error: #{e.message} for accounts #{@account_ids}"
raise
end
def holdings_balance_series
build_series_for(:holdings_balance)
rescue => e
Rails.logger.error "Holdings balance series error: #{e.message} for accounts #{@account_ids}"
raise
end
private
@@ -61,6 +70,9 @@ class Balance::ChartSeriesBuilder
sign_multiplier: sign_multiplier
}
])
rescue => e
Rails.logger.error "Query data error: #{e.message} for accounts #{account_ids}, period #{period.start_date} to #{period.end_date}"
raise
end
# Since the query aggregates the *net* of assets - liabilities, this means that if we're looking at

View File

@@ -1,7 +1,7 @@
class BalanceSheet
include Monetizable
monetize :total_assets, :total_liabilities, :net_worth
monetize :net_worth
attr_reader :family
@@ -9,99 +9,36 @@ class BalanceSheet
@family = family
end
def total_assets
totals_query.filter { |t| t.classification == "asset" }.sum(&:converted_balance)
def assets
@assets ||= ClassificationGroup.new(
classification: "asset",
currency: family.currency,
accounts: account_totals.asset_accounts
)
end
def total_liabilities
totals_query.filter { |t| t.classification == "liability" }.sum(&:converted_balance)
end
def net_worth
total_assets - total_liabilities
def liabilities
@liabilities ||= ClassificationGroup.new(
classification: "liability",
currency: family.currency,
accounts: account_totals.liability_accounts
)
end
def classification_groups
Rails.cache.fetch(family.build_cache_key("bs_classification_groups")) do
asset_groups = account_groups("asset")
liability_groups = account_groups("liability")
[
ClassificationGroup.new(
key: "asset",
display_name: "Assets",
icon: "plus",
total_money: total_assets_money,
account_groups: asset_groups,
syncing?: asset_groups.any?(&:syncing?)
),
ClassificationGroup.new(
key: "liability",
display_name: "Debts",
icon: "minus",
total_money: total_liabilities_money,
account_groups: liability_groups,
syncing?: liability_groups.any?(&:syncing?)
)
]
end
[ assets, liabilities ]
end
def account_groups(classification = nil)
Rails.cache.fetch(family.build_cache_key("bs_account_groups_#{classification || 'all'}")) do
classification_accounts = classification ? totals_query.filter { |t| t.classification == classification } : totals_query
classification_total = classification_accounts.sum(&:converted_balance)
def account_groups
[ assets.account_groups, liabilities.account_groups ].flatten
end
account_groups = classification_accounts.group_by(&:accountable_type)
.transform_keys { |k| Accountable.from_type(k) }
groups = account_groups.map do |accountable, accounts|
group_total = accounts.sum(&:converted_balance)
key = accountable.model_name.param_key
group = AccountGroup.new(
id: classification ? "#{classification}_#{key}_group" : "#{key}_group",
key: key,
name: accountable.display_name,
classification: accountable.classification,
total: group_total,
total_money: Money.new(group_total, currency),
weight: classification_total.zero? ? 0 : group_total / classification_total.to_d * 100,
missing_rates?: accounts.any? { |a| a.missing_rates? },
color: accountable.color,
syncing?: accounts.any?(&:is_syncing),
accounts: accounts.map do |account|
account
end.sort_by(&:converted_balance).reverse
)
group
end
groups.sort_by do |group|
manual_order = Accountable::TYPES
type_name = group.key.camelize
manual_order.index(type_name) || Float::INFINITY
end
end
def net_worth
assets.total - liabilities.total
end
def net_worth_series(period: Period.last_30_days)
memo_key = [ period.start_date, period.end_date ].compact.join("_")
@net_worth_series ||= {}
account_ids = active_accounts.pluck(:id)
builder = (@net_worth_series[memo_key] ||= Balance::ChartSeriesBuilder.new(
account_ids: account_ids,
currency: currency,
period: period,
favorable_direction: "up"
))
builder.balance_series
net_worth_series_builder.net_worth_series(period: period)
end
def currency
@@ -109,32 +46,19 @@ class BalanceSheet
end
def syncing?
classification_groups.any? { |group| group.syncing? }
sync_status_monitor.syncing?
end
private
ClassificationGroup = Struct.new(:key, :display_name, :icon, :total_money, :account_groups, :syncing?, keyword_init: true)
AccountGroup = Struct.new(:id, :key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, :syncing?, keyword_init: true)
def active_accounts
family.accounts.active.with_attached_logo
def sync_status_monitor
@sync_status_monitor ||= SyncStatusMonitor.new(family)
end
def totals_query
@totals_query ||= active_accounts
.joins(ActiveRecord::Base.sanitize_sql_array([ "LEFT JOIN exchange_rates ON exchange_rates.date = CURRENT_DATE AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?", currency ]))
.joins(ActiveRecord::Base.sanitize_sql_array([
"LEFT JOIN syncs ON syncs.syncable_id = accounts.id AND syncs.syncable_type = 'Account' AND syncs.status IN (?) AND syncs.created_at > ?",
%w[pending syncing],
Sync::VISIBLE_FOR.ago
]))
.select(
"accounts.*",
"SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance",
"COUNT(syncs.id) > 0 as is_syncing",
ActiveRecord::Base.sanitize_sql_array([ "COUNT(CASE WHEN accounts.currency <> ? AND exchange_rates.rate IS NULL THEN 1 END) as missing_rates", currency ])
)
.group(:classification, :accountable_type, :id)
.to_a
def account_totals
@account_totals ||= AccountTotals.new(family, sync_status_monitor: sync_status_monitor)
end
def net_worth_series_builder
@net_worth_series_builder ||= NetWorthSeriesBuilder.new(family)
end
end

View File

@@ -0,0 +1,61 @@
class BalanceSheet::AccountGroup
include Monetizable
monetize :total, as: :total_money
attr_reader :name, :color, :accountable_type, :accounts
def initialize(name:, color:, accountable_type:, accounts:, classification_group:)
@name = name
@color = color
@accountable_type = accountable_type
@accounts = accounts
@classification_group = classification_group
end
# A stable DOM id for this group.
# Example outputs:
# dom_id(tab: :asset) # => "asset_depository"
# dom_id(tab: :all, mobile: true) # => "mobile_all_depository"
#
# Keeping all of the logic here means the view layer and broadcaster only
# need to ask the object for its DOM id instead of rebuilding string
# fragments in multiple places.
def dom_id(tab: nil, mobile: false)
parts = []
parts << "mobile" if mobile
parts << (tab ? tab.to_s : classification.to_s)
parts << key
parts.compact.join("_")
end
def key
accountable_type.to_s.underscore
end
def total
accounts.sum(&:converted_balance)
end
def weight
return 0 if classification_group.total.zero?
total / classification_group.total.to_d * 100
end
def syncing?
accounts.any?(&:syncing?)
end
# "asset" or "liability"
def classification
classification_group.classification
end
def currency
classification_group.currency
end
private
attr_reader :classification_group
end

View File

@@ -0,0 +1,63 @@
class BalanceSheet::AccountTotals
def initialize(family, sync_status_monitor:)
@family = family
@sync_status_monitor = sync_status_monitor
end
def asset_accounts
@asset_accounts ||= account_rows.filter { |t| t.classification == "asset" }
end
def liability_accounts
@liability_accounts ||= account_rows.filter { |t| t.classification == "liability" }
end
private
attr_reader :family, :sync_status_monitor
AccountRow = Data.define(:account, :converted_balance, :is_syncing) do
def syncing? = is_syncing
# Allows Rails path helpers to generate URLs from the wrapper
def to_param = account.to_param
delegate_missing_to :account
end
def active_accounts
@active_accounts ||= family.accounts.active.with_attached_logo
end
def account_rows
@account_rows ||= query.map do |account_row|
AccountRow.new(
account: account_row,
converted_balance: account_row.converted_balance,
is_syncing: sync_status_monitor.account_syncing?(account_row)
)
end
end
def cache_key
family.build_cache_key(
"balance_sheet_account_rows",
invalidate_on_data_updates: true
)
end
def query
@query ||= Rails.cache.fetch(cache_key) do
active_accounts
.joins(ActiveRecord::Base.sanitize_sql_array([
"LEFT JOIN exchange_rates ON exchange_rates.date = ? AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?",
Date.current,
family.currency
]))
.select(
"accounts.*",
"SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance"
)
.group(:classification, :accountable_type, :id)
.to_a
end
end
end

View File

@@ -0,0 +1,61 @@
class BalanceSheet::ClassificationGroup
include Monetizable
monetize :total, as: :total_money
attr_reader :classification, :currency
def initialize(classification:, currency:, accounts:)
@classification = normalize_classification!(classification)
@name = name
@currency = currency
@accounts = accounts
end
def name
classification.titleize.pluralize
end
def icon
classification == "asset" ? "plus" : "minus"
end
def total
accounts.sum(&:converted_balance)
end
def syncing?
accounts.any?(&:syncing?)
end
# For now, we group by accountable type. This can be extended in the future to support arbitrary user groupings.
def account_groups
groups = accounts.group_by(&:accountable_type)
.transform_keys { |at| Accountable.from_type(at) }
.map do |accountable, account_rows|
BalanceSheet::AccountGroup.new(
name: accountable.display_name,
color: accountable.color,
accountable_type: accountable,
accounts: account_rows,
classification_group: self
)
end
# Sort the groups using the manual order defined by Accountable::TYPES so that
# the UI displays account groups in a predictable, domain-specific sequence.
groups.sort_by do |group|
manual_order = Accountable::TYPES
type_name = group.key.camelize
manual_order.index(type_name) || Float::INFINITY
end
end
private
attr_reader :accounts
def normalize_classification!(classification)
raise ArgumentError, "Invalid classification: #{classification}" unless %w[asset liability].include?(classification)
classification
end
end

View File

@@ -0,0 +1,38 @@
class BalanceSheet::NetWorthSeriesBuilder
def initialize(family)
@family = family
end
def net_worth_series(period: Period.last_30_days)
Rails.cache.fetch(cache_key(period)) do
builder = Balance::ChartSeriesBuilder.new(
account_ids: active_account_ids,
currency: family.currency,
period: period,
favorable_direction: "up"
)
builder.balance_series
end
end
private
attr_reader :family
def active_account_ids
@active_account_ids ||= family.accounts.active.with_attached_logo.pluck(:id)
end
def cache_key(period)
key = [
"balance_sheet_net_worth_series",
period.start_date,
period.end_date
].compact.join("_")
family.build_cache_key(
key,
invalidate_on_data_updates: true
)
end
end

View File

@@ -0,0 +1,35 @@
class BalanceSheet::SyncStatusMonitor
def initialize(family)
@family = family
end
def syncing?
syncing_account_ids.any?
end
def account_syncing?(account)
syncing_account_ids.include?(account.id)
end
private
attr_reader :family
def syncing_account_ids
Rails.cache.fetch(cache_key) do
Sync.visible
.where(syncable_type: "Account", syncable_id: family.accounts.active.pluck(:id))
.pluck(:syncable_id)
.to_set
end
end
# We re-fetch the set of syncing IDs any time a sync that belongs to the family is started or completed.
# This ensures we're always fetching the latest sync statuses without re-querying on every page load in idle times (no syncs happening).
def cache_key
[
"balance_sheet_sync_status",
family.id,
family.latest_sync_activity_at
].join("_")
end
end

View File

@@ -6,7 +6,7 @@ module Syncable
end
def syncing?
raise NotImplementedError, "Subclasses must implement the syncing? method"
syncs.visible.any?
end
# Schedules a sync for syncable. If there is an existing sync pending/syncing for this syncable,

View File

@@ -0,0 +1,28 @@
# SAFETY: Only operates in development/test environments to prevent data loss
class Demo::DataCleaner
SAFE_ENVIRONMENTS = %w[development test]
def initialize
ensure_safe_environment!
end
# Main entry point for destroying all demo data
def destroy_everything!
Family.destroy_all
Setting.destroy_all
InviteCode.destroy_all
ExchangeRate.destroy_all
Security.destroy_all
Security::Price.destroy_all
puts "Data cleared"
end
private
def ensure_safe_environment!
unless SAFE_ENVIRONMENTS.include?(Rails.env)
raise SecurityError, "Demo::DataCleaner can only be used in #{SAFE_ENVIRONMENTS.join(', ')} environments. Current: #{Rails.env}"
end
end
end

File diff suppressed because it is too large Load Diff

View File

@@ -35,15 +35,6 @@ class Family < ApplicationRecord
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
# If any accounts or plaid items are syncing, the family is also syncing, even if a formal "Family Sync" is not running.
def syncing?
Sync.joins("LEFT JOIN plaid_items ON plaid_items.id = syncs.syncable_id AND syncs.syncable_type = 'PlaidItem'")
.joins("LEFT JOIN accounts ON accounts.id = syncs.syncable_id AND syncs.syncable_type = 'Account'")
.where("syncs.syncable_id = ? OR accounts.family_id = ? OR plaid_items.family_id = ?", id, id, id)
.visible
.exists?
end
def assigned_merchants
merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
Merchant.where(id: merchant_ids)
@@ -100,16 +91,27 @@ class Family < ApplicationRecord
entries.order(:date).first&.date || Date.current
end
# Cache key that is invalidated when any of the family's entries are updated (which affect rollups and other calculations)
def build_cache_key(key)
# Used for invalidating family / balance sheet related aggregation queries
def build_cache_key(key, invalidate_on_data_updates: false)
# Our data sync process updates this timestamp whenever any family account successfully completes a data update.
# By including it in the cache key, we can expire caches every time family account data changes.
data_invalidation_key = invalidate_on_data_updates ? latest_sync_completed_at : nil
[
"family",
id,
key,
entries.maximum(:updated_at)
data_invalidation_key
].compact.join("_")
end
# Used for invalidating entry related aggregation queries
def entries_cache_version
@entries_cache_version ||= begin
ts = entries.maximum(:updated_at)
ts.present? ? ts.to_i : 0
end
end
def self_hoster?
Rails.application.config.app_mode.self_hosted?
end

View File

@@ -53,6 +53,9 @@ module Family::AutoTransferMatchable
outflow_transaction_id: match.outflow_transaction_id,
)
Transaction.find(match.inflow_transaction_id).update!(kind: "funds_movement")
Transaction.find(match.outflow_transaction_id).update!(kind: Transfer.kind_for_account(Transaction.find(match.outflow_transaction_id).entry.account))
used_transaction_ids << match.inflow_transaction_id
used_transaction_ids << match.outflow_transaction_id
end

View File

@@ -20,8 +20,7 @@ class IncomeStatement
ScopeTotals.new(
transactions_count: result.sum(&:transactions_count),
income_money: Money.new(total_income, family.currency),
expense_money: Money.new(total_expense, family.currency),
missing_exchange_rates?: result.any?(&:missing_exchange_rates?)
expense_money: Money.new(total_expense, family.currency)
)
end
@@ -54,8 +53,8 @@ class IncomeStatement
end
private
ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money, :missing_exchange_rates?)
PeriodTotal = Data.define(:classification, :total, :currency, :missing_exchange_rates?, :category_totals)
ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money)
PeriodTotal = Data.define(:classification, :total, :currency, :category_totals)
CategoryTotal = Data.define(:category, :total, :currency, :weight)
def categories
@@ -95,25 +94,30 @@ class IncomeStatement
classification: classification,
total: category_totals.reject { |ct| ct.category.subcategory? }.sum(&:total),
currency: family.currency,
missing_exchange_rates?: totals.any?(&:missing_exchange_rates?),
category_totals: category_totals
)
end
def family_stats(interval: "month")
@family_stats ||= {}
@family_stats[interval] ||= FamilyStats.new(family, interval:).call
@family_stats[interval] ||= Rails.cache.fetch([
"income_statement", "family_stats", family.id, interval, family.entries_cache_version
]) { FamilyStats.new(family, interval:).call }
end
def category_stats(interval: "month")
@category_stats ||= {}
@category_stats[interval] ||= CategoryStats.new(family, interval:).call
@category_stats[interval] ||= Rails.cache.fetch([
"income_statement", "category_stats", family.id, interval, family.entries_cache_version
]) { CategoryStats.new(family, interval:).call }
end
def totals_query(transactions_scope:)
@totals_query_cache ||= {}
cache_key = Digest::MD5.hexdigest(transactions_scope.to_sql)
@totals_query_cache[cache_key] ||= Totals.new(family, transactions_scope: transactions_scope).call
sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql)
Rails.cache.fetch([
"income_statement", "totals_query", family.id, sql_hash, family.entries_cache_version
]) { Totals.new(family, transactions_scope: transactions_scope).call }
end
def monetizable_currency

View File

@@ -1,43 +0,0 @@
module IncomeStatement::BaseQuery
private
def base_query_sql(family:, interval:, transactions_scope:)
sql = <<~SQL
SELECT
c.id as category_id,
c.parent_id as parent_category_id,
date_trunc(:interval, ae.date) as date,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
SUM(ae.amount * COALESCE(er.rate, 1)) as total,
COUNT(ae.id) as transactions_count,
BOOL_OR(ae.currency <> :target_currency AND er.rate IS NULL) as missing_exchange_rates
FROM (#{transactions_scope.to_sql}) at
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
LEFT JOIN categories c ON c.id = at.category_id
LEFT JOIN (
SELECT t.*, t.id as transfer_id, a.accountable_type
FROM transfers t
JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id
AND ae.entryable_type = 'Transaction'
JOIN accounts a ON a.id = ae.account_id
) transfer_info ON (
transfer_info.inflow_transaction_id = at.id OR
transfer_info.outflow_transaction_id = at.id
)
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE (
transfer_info.transfer_id IS NULL OR
(ae.amount > 0 AND transfer_info.accountable_type = 'Loan')
)
GROUP BY 1, 2, 3, 4
SQL
ActiveRecord::Base.sanitize_sql_array([
sql,
{ target_currency: family.currency, interval: interval }
])
end
end

View File

@@ -1,40 +1,62 @@
class IncomeStatement::CategoryStats
include IncomeStatement::BaseQuery
def initialize(family, interval: "month")
@family = family
@interval = interval
end
def call
ActiveRecord::Base.connection.select_all(query_sql).map do |row|
ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row|
StatRow.new(
category_id: row["category_id"],
classification: row["classification"],
median: row["median"],
avg: row["avg"],
missing_exchange_rates?: row["missing_exchange_rates"]
avg: row["avg"]
)
end
end
private
StatRow = Data.define(:category_id, :classification, :median, :avg, :missing_exchange_rates?)
StatRow = Data.define(:category_id, :classification, :median, :avg)
def sanitized_query_sql
ActiveRecord::Base.sanitize_sql_array([
query_sql,
{
target_currency: @family.currency,
interval: @interval,
family_id: @family.id
}
])
end
def query_sql
base_sql = base_query_sql(family: @family, interval: @interval, transactions_scope: @family.transactions.active)
<<~SQL
WITH base_totals AS (
#{base_sql}
WITH period_totals AS (
SELECT
c.id as category_id,
date_trunc(:interval, ae.date) as period,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
SUM(ae.amount * COALESCE(er.rate, 1)) as total
FROM transactions t
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction'
JOIN accounts a ON a.id = ae.account_id
LEFT JOIN categories c ON c.id = t.category_id
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE a.family_id = :family_id
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND ae.excluded = false
GROUP BY c.id, period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
)
SELECT
category_id,
classification,
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
ABS(AVG(total)) as avg,
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
FROM base_totals
category_id,
classification,
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
ABS(AVG(total)) as avg
FROM period_totals
GROUP BY category_id, classification;
SQL
end

View File

@@ -1,46 +1,58 @@
class IncomeStatement::FamilyStats
include IncomeStatement::BaseQuery
def initialize(family, interval: "month")
@family = family
@interval = interval
end
def call
ActiveRecord::Base.connection.select_all(query_sql).map do |row|
ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row|
StatRow.new(
classification: row["classification"],
median: row["median"],
avg: row["avg"],
missing_exchange_rates?: row["missing_exchange_rates"]
avg: row["avg"]
)
end
end
private
StatRow = Data.define(:classification, :median, :avg, :missing_exchange_rates?)
StatRow = Data.define(:classification, :median, :avg)
def sanitized_query_sql
ActiveRecord::Base.sanitize_sql_array([
query_sql,
{
target_currency: @family.currency,
interval: @interval,
family_id: @family.id
}
])
end
def query_sql
base_sql = base_query_sql(family: @family, interval: @interval, transactions_scope: @family.transactions.active)
<<~SQL
WITH base_totals AS (
#{base_sql}
), aggregated_totals AS (
WITH period_totals AS (
SELECT
date,
classification,
SUM(total) as total,
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
FROM base_totals
GROUP BY date, classification
date_trunc(:interval, ae.date) as period,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
SUM(ae.amount * COALESCE(er.rate, 1)) as total
FROM transactions t
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction'
JOIN accounts a ON a.id = ae.account_id
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE a.family_id = :family_id
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND ae.excluded = false
GROUP BY period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
)
SELECT
classification,
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
ABS(AVG(total)) as avg,
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
FROM aggregated_totals
classification,
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
ABS(AVG(total)) as avg
FROM period_totals
GROUP BY classification;
SQL
end

View File

@@ -1,6 +1,4 @@
class IncomeStatement::Totals
include IncomeStatement::BaseQuery
def initialize(family, transactions_scope:)
@family = family
@transactions_scope = transactions_scope
@@ -13,31 +11,48 @@ class IncomeStatement::Totals
category_id: row["category_id"],
classification: row["classification"],
total: row["total"],
transactions_count: row["transactions_count"],
missing_exchange_rates?: row["missing_exchange_rates"]
transactions_count: row["transactions_count"]
)
end
end
private
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count, :missing_exchange_rates?)
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count)
def query_sql
base_sql = base_query_sql(family: @family, interval: "day", transactions_scope: @transactions_scope)
ActiveRecord::Base.sanitize_sql_array([
optimized_query_sql,
sql_params
])
end
# OPTIMIZED: Direct SUM aggregation without unnecessary time bucketing
# Eliminates CTE and intermediate date grouping for maximum performance
def optimized_query_sql
<<~SQL
WITH base_totals AS (
#{base_sql}
)
SELECT
parent_category_id,
category_id,
classification,
ABS(SUM(total)) as total,
BOOL_OR(missing_exchange_rates) as missing_exchange_rates,
SUM(transactions_count) as transactions_count
FROM base_totals
GROUP BY 1, 2, 3;
c.id as category_id,
c.parent_id as parent_category_id,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
COUNT(ae.id) as transactions_count
FROM (#{@transactions_scope.to_sql}) at
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
LEFT JOIN categories c ON c.id = at.category_id
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND ae.excluded = false
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END;
SQL
end
def sql_params
{
target_currency: @family.currency
}
end
end

View File

@@ -0,0 +1,55 @@
class MobileDevice < ApplicationRecord
belongs_to :user
belongs_to :oauth_application, class_name: "Doorkeeper::Application", optional: true
validates :device_id, presence: true, uniqueness: { scope: :user_id }
validates :device_name, presence: true
validates :device_type, presence: true, inclusion: { in: %w[ios android] }
before_validation :set_last_seen_at, on: :create
scope :active, -> { where("last_seen_at > ?", 90.days.ago) }
def active?
last_seen_at > 90.days.ago
end
def update_last_seen!
update_column(:last_seen_at, Time.current)
end
def create_oauth_application!
return oauth_application if oauth_application.present?
app = Doorkeeper::Application.create!(
name: "Mobile App - #{device_id}",
redirect_uri: "maybe://oauth/callback", # Custom scheme for mobile
scopes: "read_write", # Use the configured scope
confidential: false # Public client for mobile
)
# Store the association
update!(oauth_application: app)
app
end
def active_tokens
return Doorkeeper::AccessToken.none unless oauth_application
Doorkeeper::AccessToken
.where(application: oauth_application)
.where(resource_owner_id: user_id)
.where(revoked_at: nil)
.where("expires_in IS NULL OR created_at + expires_in * interval '1 second' > ?", Time.current)
end
def revoke_all_tokens!
active_tokens.update_all(revoked_at: Time.current)
end
private
def set_last_seen_at
self.last_seen_at ||= Time.current
end
end

View File

@@ -46,14 +46,6 @@ class PlaidItem < ApplicationRecord
DestroyJob.perform_later(self)
end
def syncing?
Sync.joins("LEFT JOIN accounts a ON a.id = syncs.syncable_id AND syncs.syncable_type = 'Account'")
.joins("LEFT JOIN plaid_accounts pa ON pa.id = a.plaid_account_id")
.where("syncs.syncable_id = ? OR pa.plaid_item_id = ?", id, id)
.visible
.exists?
end
def import_latest_plaid_data
PlaidItem::Importer.new(self, plaid_provider: plaid_provider).import
end

View File

@@ -4,7 +4,7 @@ class Provider::Openai < Provider
# Subclass so errors caught in this provider are raised as Provider::Openai::Error
Error = Class.new(Provider::Error)
MODELS = %w[gpt-4o]
MODELS = %w[gpt-4.1]
def initialize(access_token)
@client = ::OpenAI::Client.new(access_token: access_token)

View File

@@ -44,6 +44,9 @@ class Provider::PlaidSandbox < Provider::Plaid
Rails.application.config.plaid
)
# Force sandbox environment for PlaidSandbox regardless of Rails config
api_client.config.server_index = Plaid::Configuration::Environment["sandbox"]
Plaid::PlaidApi.new(api_client)
end
end

View File

@@ -53,7 +53,7 @@ module Security::Provided
price = response.data
Security::Price.find_or_create_by!(
security_id: price.security.id,
security_id: self.id,
date: price.date,
price: price.price,
currency: price.currency

View File

@@ -33,10 +33,6 @@ class Series
start_date: start_date,
end_date: end_date,
interval: interval,
trend: Trend.new(
current: ordered.last[:value],
previous: ordered.first[:value]
),
values: [ nil, *ordered ].each_cons(2).map do |prev_value, curr_value|
Value.new(
date: curr_value[:date],

View File

@@ -29,13 +29,13 @@ class Sync < ApplicationRecord
state :failed
state :stale
after_all_transitions :log_status_change
after_all_transitions :handle_transition
event :start, after_commit: :report_warnings do
event :start, after_commit: :handle_start_transition do
transitions from: :pending, to: :syncing
end
event :complete do
event :complete, after_commit: :handle_completion_transition do
transitions from: :syncing, to: :completed
end
@@ -163,9 +163,30 @@ class Sync < ApplicationRecord
end
end
def handle_start_transition
report_warnings
end
def handle_transition
log_status_change
family.touch(:latest_sync_activity_at)
end
def handle_completion_transition
family.touch(:latest_sync_completed_at)
end
def window_valid
if window_start_date && window_end_date && window_start_date > window_end_date
errors.add(:window_end_date, "must be greater than window_start_date")
end
end
def family
if syncable.is_a?(Family)
syncable
else
syncable.family
end
end
end

View File

@@ -0,0 +1,113 @@
class Trade::CreateForm
include ActiveModel::Model
attr_accessor :account, :date, :amount, :currency, :qty,
:price, :ticker, :manual_ticker, :type, :transfer_account_id
# Either creates a trade, transaction, or transfer based on type
# Returns the model, regardless of success or failure
def create
case type
when "buy", "sell"
create_trade
when "interest"
create_interest_income
when "deposit", "withdrawal"
create_transfer
end
end
private
# Users can either look up a ticker from our provider (Synth) or enter a manual, "offline" ticker (that we won't fetch prices for)
def security
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
Security::Resolver.new(
ticker_symbol,
exchange_operating_mic: exchange_operating_mic
).resolve
end
def create_trade
prefix = type == "sell" ? "Sell " : "Buy "
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
signed_qty = type == "sell" ? -qty.to_d : qty.to_d
signed_amount = signed_qty * price.to_d
trade_entry = account.entries.new(
name: trade_name,
date: date,
amount: signed_amount,
currency: currency,
entryable: Trade.new(
qty: signed_qty,
price: price,
currency: currency,
security: security
)
)
if trade_entry.save
trade_entry.lock_saved_attributes!
account.sync_later
end
trade_entry
end
def create_interest_income
signed_amount = amount.to_d * -1
entry = account.entries.build(
name: "Interest payment",
date: date,
amount: signed_amount,
currency: currency,
entryable: Transaction.new
)
if entry.save
entry.lock_saved_attributes!
account.sync_later
end
entry
end
def create_transfer
if transfer_account_id.present?
from_account_id = type == "withdrawal" ? account.id : transfer_account_id
to_account_id = type == "withdrawal" ? transfer_account_id : account.id
Transfer::Creator.new(
family: account.family,
source_account_id: from_account_id,
destination_account_id: to_account_id,
date: date,
amount: amount
).create
else
create_unlinked_transfer
end
end
# If user doesn't provide the reciprocal account, it's a regular transaction
def create_unlinked_transfer
signed_amount = type == "deposit" ? amount.to_d * -1 : amount.to_d
entry = account.entries.build(
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
date: date,
amount: signed_amount,
currency: currency,
entryable: Transaction.new
)
if entry.save
entry.lock_saved_attributes!
account.sync_later
end
entry
end
end

View File

@@ -1,137 +0,0 @@
class TradeBuilder
include ActiveModel::Model
attr_accessor :account, :date, :amount, :currency, :qty,
:price, :ticker, :manual_ticker, :type, :transfer_account_id
attr_reader :buildable
def initialize(attributes = {})
super
@buildable = set_buildable
end
def save
buildable.save
end
def lock_saved_attributes!
if buildable.is_a?(Transfer)
buildable.inflow_transaction.entry.lock_saved_attributes!
buildable.outflow_transaction.entry.lock_saved_attributes!
else
buildable.lock_saved_attributes!
end
end
def entryable
return nil if buildable.is_a?(Transfer)
buildable.entryable
end
def errors
buildable.errors
end
def sync_account_later
buildable.sync_account_later
end
private
def set_buildable
case type
when "buy", "sell"
build_trade
when "deposit", "withdrawal"
build_transfer
when "interest"
build_interest
else
raise "Unknown trade type: #{type}"
end
end
def build_trade
prefix = type == "sell" ? "Sell " : "Buy "
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
account.entries.new(
name: trade_name,
date: date,
amount: signed_amount,
currency: currency,
entryable: Trade.new(
qty: signed_qty,
price: price,
currency: currency,
security: security
)
)
end
def build_transfer
transfer_account = family.accounts.find(transfer_account_id) if transfer_account_id.present?
if transfer_account
from_account = type == "withdrawal" ? account : transfer_account
to_account = type == "withdrawal" ? transfer_account : account
Transfer.from_accounts(
from_account: from_account,
to_account: to_account,
date: date,
amount: signed_amount
)
else
account.entries.build(
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
date: date,
amount: signed_amount,
currency: currency,
entryable: Transaction.new
)
end
end
def build_interest
account.entries.build(
name: "Interest payment",
date: date,
amount: signed_amount,
currency: currency,
entryable: Transaction.new
)
end
def signed_qty
return nil unless type.in?([ "buy", "sell" ])
type == "sell" ? -qty.to_d : qty.to_d
end
def signed_amount
case type
when "buy", "sell"
signed_qty * price.to_d
when "deposit", "withdrawal"
type == "deposit" ? -amount.to_d : amount.to_d
when "interest"
amount.to_d * -1
end
end
def family
account.family
end
# Users can either look up a ticker from our provider (Synth) or enter a manual, "offline" ticker (that we won't fetch prices for)
def security
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
Security::Resolver.new(
ticker_symbol,
exchange_operating_mic: exchange_operating_mic
).resolve
end
end

View File

@@ -9,10 +9,17 @@ class Transaction < ApplicationRecord
accepts_nested_attributes_for :taggings, allow_destroy: true
class << self
def search(params)
Search.new(params).build_query(all)
end
enum :kind, {
standard: "standard", # A regular transaction, included in budget analytics
funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics
cc_payment: "cc_payment", # A CC payment, excluded from budget analytics (CC payments offset the sum of expense transactions)
loan_payment: "loan_payment", # A payment to a Loan account, treated as an expense in budgets
one_time: "one_time" # A one-time expense/income, excluded from budget analytics
}
# Overarching grouping method for all transfer-type transactions
def transfer?
funds_movement? || cc_payment? || loan_payment?
end
def set_category!(category)

View File

@@ -13,45 +13,87 @@ class Transaction::Search
attribute :categories, array: true
attribute :merchants, array: true
attribute :tags, array: true
attribute :active_accounts_only, :boolean, default: true
def build_query(scope)
query = scope.joins(entry: :account)
.joins(transfer_join)
attr_reader :family
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 = EntrySearch.apply_search_filter(query, search)
query = EntrySearch.apply_date_filters(query, start_date, end_date)
query = EntrySearch.apply_amount_filter(query, amount, amount_operator)
query = EntrySearch.apply_accounts_filter(query, accounts, account_ids)
def initialize(family, filters: {})
@family = family
super(filters)
end
query
def transactions_scope
@transactions_scope ||= begin
# This already joins entries + accounts. To avoid expensive double-joins, don't join them again (causes full table scan)
query = family.transactions
query = apply_active_accounts_filter(query, active_accounts_only)
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 = EntrySearch.apply_search_filter(query, search)
query = EntrySearch.apply_date_filters(query, start_date, end_date)
query = EntrySearch.apply_amount_filter(query, amount, amount_operator)
query = EntrySearch.apply_accounts_filter(query, accounts, account_ids)
query
end
end
# Computes totals for the specific search
def totals
@totals ||= begin
Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do
result = transactions_scope
.select(
"COALESCE(SUM(CASE WHEN entries.amount >= 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
"COALESCE(SUM(CASE WHEN entries.amount < 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
"COUNT(entries.id) as transactions_count"
)
.joins(
ActiveRecord::Base.sanitize_sql_array([
"LEFT JOIN exchange_rates er ON (er.date = entries.date AND er.from_currency = entries.currency AND er.to_currency = ?)",
family.currency
])
)
.take
Totals.new(
count: result.transactions_count.to_i,
income_money: Money.new(result.income_total.to_i, family.currency),
expense_money: Money.new(result.expense_total.to_i, family.currency)
)
end
end
end
def cache_key_base
[
family.id,
Digest::SHA256.hexdigest(attributes.sort.to_h.to_json), # cached by filters
family.entries_cache_version
].join("/")
end
private
def transfer_join
<<~SQL
LEFT JOIN (
SELECT t.*, t.id as transfer_id, a.accountable_type
FROM transfers t
JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id
AND ae.entryable_type = 'Transaction'
JOIN accounts a ON a.id = ae.account_id
) transfer_info ON (
transfer_info.inflow_transaction_id = transactions.id OR
transfer_info.outflow_transaction_id = transactions.id
)
SQL
Totals = Data.define(:count, :income_money, :expense_money)
def apply_active_accounts_filter(query, active_accounts_only_filter)
if active_accounts_only_filter
query.where(accounts: { is_active: true })
else
query
end
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.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment'))
)",
categories
)
@@ -67,7 +109,7 @@ class Transaction::Search
return query unless types.present?
return query if types.sort == [ "expense", "income", "transfer" ]
transfer_condition = "transfer_info.transfer_id IS NOT NULL"
transfer_condition = "transactions.kind IN ('funds_movement', 'cc_payment', 'loan_payment')"
expense_condition = "entries.amount >= 0"
income_condition = "entries.amount <= 0"

View File

@@ -14,10 +14,6 @@ module Transaction::Transferable
transfer_as_inflow || transfer_as_outflow
end
def transfer?
transfer.present?
end
def transfer_match_candidates
candidates_scope = if self.entry.amount.negative?
family_matches_scope.where("inflow_candidates.entryable_id = ?", self.id)

View File

@@ -13,34 +13,14 @@ class Transfer < ApplicationRecord
validate :transfer_has_same_family
class << self
def from_accounts(from_account:, to_account:, date:, amount:)
# Attempt to convert the amount to the to_account's currency.
# If the conversion fails, use the original amount.
converted_amount = begin
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
rescue Money::ConversionError
Money.new(amount.abs, from_account.currency)
def kind_for_account(account)
if account.loan?
"loan_payment"
elsif account.liability?
"cc_payment"
else
"funds_movement"
end
new(
inflow_transaction: Transaction.new(
entry: to_account.entries.build(
amount: converted_amount.amount.abs * -1,
currency: converted_amount.currency.iso_code,
date: date,
name: "Transfer from #{from_account.name}",
)
),
outflow_transaction: Transaction.new(
entry: from_account.entries.build(
amount: amount.abs,
currency: from_account.currency,
date: date,
name: "Transfer to #{to_account.name}",
)
),
status: "confirmed"
)
end
end
@@ -51,19 +31,28 @@ class Transfer < ApplicationRecord
end
end
# Once transfer is destroyed, we need to mark the denormalized kind fields on the transactions
def destroy!
Transfer.transaction do
inflow_transaction.update!(kind: "standard")
outflow_transaction.update!(kind: "standard")
super
end
end
def confirm!
update!(status: "confirmed")
end
def date
inflow_transaction.entry.date
end
def sync_account_later
inflow_transaction&.entry&.sync_account_later
outflow_transaction&.entry&.sync_account_later
end
def belongs_to_family?(family)
family.transactions.include?(inflow_transaction)
end
def to_account
inflow_transaction&.entry&.account
end
@@ -89,6 +78,24 @@ class Transfer < ApplicationRecord
to_account&.liability?
end
def loan_payment?
outflow_transaction&.kind == "loan_payment"
end
def liability_payment?
outflow_transaction&.kind == "cc_payment"
end
def regular_transfer?
outflow_transaction&.kind == "funds_movement"
end
def transfer_type
return "loan_payment" if loan_payment?
return "liability_payment" if liability_payment?
"transfer"
end
def categorizable?
to_account&.accountable_type == "Loan"
end

View File

@@ -0,0 +1,85 @@
class Transfer::Creator
def initialize(family:, source_account_id:, destination_account_id:, date:, amount:)
@family = family
@source_account = family.accounts.find(source_account_id) # early throw if not found
@destination_account = family.accounts.find(destination_account_id) # early throw if not found
@date = date
@amount = amount.to_d
end
def create
transfer = Transfer.new(
inflow_transaction: inflow_transaction,
outflow_transaction: outflow_transaction,
status: "confirmed"
)
if transfer.save
source_account.sync_later
destination_account.sync_later
end
transfer
end
private
attr_reader :family, :source_account, :destination_account, :date, :amount
def outflow_transaction
name = "#{name_prefix} to #{destination_account.name}"
Transaction.new(
kind: outflow_transaction_kind,
entry: source_account.entries.build(
amount: amount.abs,
currency: source_account.currency,
date: date,
name: name,
)
)
end
def inflow_transaction
name = "#{name_prefix} from #{source_account.name}"
Transaction.new(
kind: "funds_movement",
entry: destination_account.entries.build(
amount: inflow_converted_money.amount.abs * -1,
currency: destination_account.currency,
date: date,
name: name,
)
)
end
# If destination account has different currency, its transaction should show up as converted
# Future improvement: instead of a 1:1 conversion fallback, add a UI/UX flow for missing rates
def inflow_converted_money
Money.new(amount.abs, source_account.currency)
.exchange_to(
destination_account.currency,
date: date,
fallback_rate: 1.0
)
end
# The "expense" side of a transfer is treated different in analytics based on where it goes.
def outflow_transaction_kind
if destination_account.loan?
"loan_payment"
elsif destination_account.liability?
"cc_payment"
else
"funds_movement"
end
end
def name_prefix
if destination_account.liability?
"Payment"
else
"Transfer"
end
end
end

View File

@@ -5,6 +5,9 @@ class User < ApplicationRecord
belongs_to :last_viewed_chat, class_name: "Chat", optional: true
has_many :sessions, dependent: :destroy
has_many :chats, dependent: :destroy
has_many :api_keys, dependent: :destroy
has_many :mobile_devices, dependent: :destroy
has_many :invitations, foreign_key: :inviter_id, dependent: :destroy
has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy
has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy
accepts_nested_attributes_for :family, update_only: true

View File

@@ -0,0 +1,85 @@
class ApiRateLimiter
# Rate limit tiers (requests per hour)
RATE_LIMITS = {
standard: 100,
premium: 1000,
enterprise: 10000
}.freeze
DEFAULT_TIER = :standard
def initialize(api_key)
@api_key = api_key
@redis = Redis.new
end
# Check if the API key has exceeded its rate limit
def rate_limit_exceeded?
current_count >= rate_limit
end
# Increment the request count for this API key
def increment_request_count!
key = redis_key
current_time = Time.current.to_i
window_start = (current_time / 3600) * 3600 # Hourly window
@redis.multi do |transaction|
# Use a sliding window with hourly buckets
transaction.hincrby(key, window_start.to_s, 1)
transaction.expire(key, 7200) # Keep data for 2 hours to handle sliding window
end
end
# Get current request count within the current hour
def current_count
key = redis_key
current_time = Time.current.to_i
window_start = (current_time / 3600) * 3600
count = @redis.hget(key, window_start.to_s)
count.to_i
end
# Get the rate limit for this API key's tier
def rate_limit
tier = determine_tier
RATE_LIMITS[tier]
end
# Calculate seconds until the rate limit resets
def reset_time
current_time = Time.current.to_i
next_window = ((current_time / 3600) + 1) * 3600
next_window - current_time
end
# Get detailed usage information
def usage_info
{
current_count: current_count,
rate_limit: rate_limit,
remaining: [ rate_limit - current_count, 0 ].max,
reset_time: reset_time,
tier: determine_tier
}
end
# Class method to get usage for an API key without incrementing
def self.usage_for(api_key)
new(api_key).usage_info
end
private
def redis_key
"api_rate_limit:#{@api_key.id}"
end
def determine_tier
# For now, all API keys are standard tier
# This can be extended later to support different tiers based on user subscription
# or API key configuration
DEFAULT_TIER
end
end

View File

@@ -1,13 +1,11 @@
<% cache Current.family.build_cache_key("#{@accountable.name}_sparkline_html") do %>
<%= turbo_frame_tag "#{@accountable.model_name.param_key}_sparkline" do %>
<div class="flex items-center justify-end gap-1">
<div class="w-8 h-3">
<%= render "shared/sparkline", id: dom_id(@accountable, :sparkline_chart), series: @series %>
</div>
<%= turbo_frame_tag "#{@accountable.model_name.param_key}_sparkline" do %>
<div class="flex items-center justify-end gap-1">
<div class="w-8 h-3">
<%= render "shared/sparkline", id: dom_id(@accountable, :sparkline_chart), series: @series %>
</div>
<%= tag.p @series.trend.percent_formatted,
<%= tag.p @series.trend.percent_formatted,
style: "color: #{@series.trend.color}",
class: "font-mono text-right text-xs font-medium text-primary" %>
</div>
<% end %>
</div>
<% end %>

View File

@@ -41,7 +41,7 @@
) %>
<div>
<% family.balance_sheet.account_groups("asset").each do |group| %>
<% family.balance_sheet.assets.account_groups.each do |group| %>
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
<% end %>
</div>
@@ -61,7 +61,7 @@
) %>
<div>
<% family.balance_sheet.account_groups("liability").each do |group| %>
<% family.balance_sheet.liabilities.account_groups.each do |group| %>
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
<% end %>
</div>
@@ -82,7 +82,7 @@
<div>
<% family.balance_sheet.account_groups.each do |group| %>
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
<%= render "accounts/accountable_group", account_group: group, mobile: mobile, all_tab: true %>
<% end %>
</div>
</div>

View File

@@ -1,24 +1,21 @@
<%# locals: (account_group:, mobile: false, open: nil, **args) %>
<%# locals: (account_group:, mobile: false, all_tab: false, open: nil, **args) %>
<div id="<%= mobile ? "mobile_#{account_group.id}" : account_group.id %>">
<div id="<%= account_group.dom_id(tab: all_tab ? :all : nil, mobile: mobile) %>">
<% is_open = open.nil? ? account_group.accounts.any? { |account| page_active?(account_path(account)) } : open %>
<%= render DisclosureComponent.new(title: account_group.name, align: :left, open: is_open) do |disclosure| %>
<% disclosure.with_summary_content do %>
<% if account_group.syncing? %>
<div class="ml-2 group-open:hidden">
<%= render partial: "shared/sync_indicator", locals: { size: "xs" } %>
</div>
<% end %>
<div class="ml-auto text-right grow">
<% if account_group.syncing? %>
<div class="space-y-1">
<div class="h-5 w-24 rounded ml-auto bg-loader"></div>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %>
<%= turbo_frame_tag "#{account_group.key}_sparkline", src: accountable_sparkline_path(account_group.key), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
<% else %>
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %>
<%= turbo_frame_tag "#{account_group.key}_sparkline", src: accountable_sparkline_path(account_group.key), loading: "lazy" do %>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
<% end %>
<% end %>
</div>
<% end %>
@@ -34,29 +31,23 @@
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
<div class="min-w-0 grow">
<%= tag.p account.name, class: "text-sm text-primary font-medium mb-0.5 truncate" %>
<div class="flex items-center gap-2 mb-0.5">
<%= tag.p account.name, class: "text-sm text-primary font-medium truncate" %>
<% if account.syncing? %>
<%= render partial: "shared/sync_indicator", locals: { size: "xs" } %>
<% end %>
</div>
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
</div>
<% if account.syncing? %>
<div class="ml-auto text-right grow h-10">
<div class="space-y-1">
<div class="h-5 w-24 bg-loader rounded ml-auto"></div>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
<div class="ml-auto text-right grow h-10">
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
</div>
<% else %>
<div class="ml-auto text-right grow h-10">
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy" do %>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div>
<% end %>
<% end %>
</div>

View File

@@ -2,25 +2,21 @@
<% trend = series.trend %>
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
<% if @account.syncing? %>
<%= render "accounts/chart_loader" %>
<% else %>
<div class="px-4">
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %>
</div>
<div class="px-4">
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %>
</div>
<div class="h-64 pb-4">
<% if series.any? %>
<div
<div class="h-64 pb-4">
<% if series.any? %>
<div
id="lineChart"
class="w-full h-full"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %>
<div class="w-full h-full flex items-center justify-center">
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
</div>
<% end %>
</div>
<% end %>
<% else %>
<div class="w-full h-full flex items-center justify-center">
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
</div>
<% end %>
</div>
<% end %>

View File

@@ -11,7 +11,7 @@
<% if show_us_link %>
<%# Default US-only Link %>
<%= link_to new_plaid_item_path(region: "us", accountable_type: accountable_type),
<%= link_to new_plaid_item_path(region: "us", accountable_type: accountable_type),
class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2",
data: { turbo_frame: "modal" } do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
@@ -23,7 +23,7 @@
<%# EU Link %>
<% if show_eu_link %>
<%= link_to new_plaid_item_path(region: "eu", accountable_type: accountable_type),
<%= link_to new_plaid_item_path(region: "eu", accountable_type: accountable_type),
class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2",
data: { turbo_frame: "modal" } do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">

View File

@@ -9,18 +9,14 @@
<div class="flex items-center gap-1">
<%= tag.p account.investment? ? "Total value" : default_value_title, class: "text-sm font-medium text-secondary" %>
<% if !account.syncing? && account.investment? %>
<% if account.investment? %>
<%= render "investments/value_tooltip", balance: account.balance_money, holdings: account.balance_money - account.cash_balance_money, cash: account.cash_balance_money %>
<% end %>
</div>
<div class="flex flex-row gap-2 items-baseline">
<% if account.syncing? %>
<div class="bg-loader rounded-md h-7 w-20"></div>
<% else %>
<%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %>
<% if account.currency != Current.family.currency %>
<%= tag.p format_money(account.balance_money.exchange_to(Current.family.currency, fallback_rate: 1)), class: "text-sm font-medium text-secondary" %>
<% end %>
<%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %>
<% if account.currency != Current.family.currency %>
<%= tag.p format_money(account.balance_money.exchange_to(Current.family.currency, fallback_rate: 1)), class: "text-sm font-medium text-secondary" %>
<% end %>
</div>
</div>

View File

@@ -10,17 +10,23 @@
<div class="flex items-center gap-3 overflow-hidden">
<%= render "accounts/logo", account: account %>
<div class="truncate">
<h2 class="font-medium text-xl truncate"><%= title || account.name %></h2>
<% if subtitle.present? %>
<p class="text-sm text-secondary"><%= subtitle %></p>
<div class="flex items-center gap-2">
<div class="truncate">
<h2 class="font-medium text-xl truncate"><%= title || account.name %></h2>
<% if subtitle.present? %>
<p class="text-sm text-secondary"><%= subtitle %></p>
<% end %>
</div>
<% if account.syncing? %>
<%= render partial: "shared/sync_indicator", locals: { size: "sm" } %>
<% end %>
</div>
</div>
<% end %>
<div class="flex items-center gap-1 ml-auto">
<% if Rails.env.development? %>
<% if Rails.env.development? || self_hosted? %>
<%= icon(
"refresh-cw",
as_button: true,

View File

@@ -1,13 +1,11 @@
<% cache Current.family.build_cache_key("account_#{@account.id}_sparkline_html") do %>
<%= turbo_frame_tag dom_id(@account, :sparkline) do %>
<div class="flex items-center justify-end gap-1">
<div class="w-8 h-5">
<%= render "shared/sparkline", id: dom_id(@account, :sparkline_chart), series: @account.sparkline_series %>
</div>
<%= tag.p @account.sparkline_series.trend.percent_formatted,
style: "color: #{@account.sparkline_series.trend.color}",
class: "font-mono text-right text-xs font-medium text-primary" %>
<%= turbo_frame_tag dom_id(@account, :sparkline) do %>
<div class="flex items-center justify-end gap-1">
<div class="w-8 h-5">
<%= render "shared/sparkline", id: dom_id(@account, :sparkline_chart), series: @sparkline_series %>
</div>
<% end %>
<%= tag.p @sparkline_series.trend.percent_formatted,
style: "color: #{@sparkline_series.trend.color}",
class: "font-mono text-right text-xs font-medium text-primary" %>
</div>
<% end %>

View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true
json.accounts @accounts do |account|
json.id account.id
json.name account.name
json.balance account.balance_money.format
json.currency account.currency
json.classification account.classification
json.account_type account.accountable_type.underscore
end
json.pagination do
json.page @pagy.page
json.per_page @per_page
json.total_count @pagy.count
json.total_pages @pagy.pages
end

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
json.id chat.id
json.title chat.title
json.error chat.error.present? ? chat.error : nil
json.created_at chat.created_at.iso8601
json.updated_at chat.updated_at.iso8601

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
json.chats @chats do |chat|
json.id chat.id
json.title chat.title
json.last_message_at chat.messages.ordered.first&.created_at&.iso8601
json.message_count chat.messages.count
json.error chat.error.present? ? chat.error : nil
json.created_at chat.created_at.iso8601
json.updated_at chat.updated_at.iso8601
end
json.pagination do
json.page @pagy.page
json.per_page @pagy.vars[:items]
json.total_count @pagy.count
json.total_pages @pagy.pages
end

View File

@@ -0,0 +1,33 @@
# frozen_string_literal: true
json.partial! "chat", chat: @chat
json.messages @messages do |message|
json.id message.id
json.type message.type.underscore
json.role message.role
json.content message.content
json.model message.ai_model if message.type == "AssistantMessage"
json.created_at message.created_at.iso8601
json.updated_at message.updated_at.iso8601
# Include tool calls for assistant messages
if message.type == "AssistantMessage" && message.tool_calls.any?
json.tool_calls message.tool_calls do |tool_call|
json.id tool_call.id
json.function_name tool_call.function_name
json.function_arguments tool_call.function_arguments
json.function_result tool_call.function_result
json.created_at tool_call.created_at.iso8601
end
end
end
if @pagy
json.pagination do
json.page @pagy.page
json.per_page @pagy.vars[:items]
json.total_count @pagy.count
json.total_pages @pagy.pages
end
end

View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
json.id @message.id
json.chat_id @message.chat_id
json.type @message.type.underscore
json.role @message.role
json.content @message.content
json.model @message.ai_model if @message.type == "AssistantMessage"
json.created_at @message.created_at.iso8601
json.updated_at @message.updated_at.iso8601
# Note: AI response will be processed asynchronously
if @message.type == "UserMessage"
json.ai_response_status "pending"
json.ai_response_message "AI response is being generated"
end

View File

@@ -0,0 +1,76 @@
# frozen_string_literal: true
json.id transaction.id
json.date transaction.entry.date
json.amount transaction.entry.amount_money.format
json.currency transaction.entry.currency
json.name transaction.entry.name
json.notes transaction.entry.notes
json.classification transaction.entry.classification
# Account information
json.account do
json.id transaction.entry.account.id
json.name transaction.entry.account.name
json.account_type transaction.entry.account.accountable_type.underscore
end
# Category information
if transaction.category.present?
json.category do
json.id transaction.category.id
json.name transaction.category.name
json.classification transaction.category.classification
json.color transaction.category.color
json.icon transaction.category.lucide_icon
end
else
json.category nil
end
# Merchant information
if transaction.merchant.present?
json.merchant do
json.id transaction.merchant.id
json.name transaction.merchant.name
end
else
json.merchant nil
end
# Tags
json.tags transaction.tags do |tag|
json.id tag.id
json.name tag.name
json.color tag.color
end
# Transfer information (if this transaction is part of a transfer)
if transaction.transfer.present?
json.transfer do
json.id transaction.transfer.id
json.amount transaction.transfer.amount_abs.format
json.currency transaction.transfer.inflow_transaction.entry.currency
# Other transaction in the transfer
if transaction.transfer.inflow_transaction == transaction
other_transaction = transaction.transfer.outflow_transaction
else
other_transaction = transaction.transfer.inflow_transaction
end
if other_transaction.present?
json.other_account do
json.id other_transaction.entry.account.id
json.name other_transaction.entry.account.name
json.account_type other_transaction.entry.account.accountable_type.underscore
end
end
end
else
json.transfer nil
end
# Additional metadata
json.created_at transaction.created_at.iso8601
json.updated_at transaction.updated_at.iso8601

View File

@@ -0,0 +1,12 @@
# frozen_string_literal: true
json.transactions @transactions do |transaction|
json.partial! "transaction", transaction: transaction
end
json.pagination do
json.page @pagy.page
json.per_page @per_page
json.total_count @pagy.count
json.total_pages @pagy.pages
end

View File

@@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! "transaction", transaction: @transaction

View File

@@ -9,81 +9,81 @@
class="bg-container placeholder:text-sm placeholder:text-secondary font-normal h-10 relative pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
data-list-filter-target="input"
data-action="list-filter#filter">
<%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
<%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
</div>
</div>
</div>
<div data-list-filter-target="list" class="flex flex-col gap-0.5 p-1.5 mt-0.5 mr-2 max-h-64 overflow-y-scroll scrollbar">
<div class="pb-2 pl-4 mr-2 text-secondary hidden" data-list-filter-target="emptyMessage">
<%= t(".no_categories") %>
</div>
<% if @categories.any? %>
<% Category::Group.for(@categories).each do |group| %>
<%= render "category/dropdowns/row", category: group.category %>
<div data-list-filter-target="list" class="flex flex-col gap-0.5 p-1.5 mt-0.5 mr-2 max-h-64 overflow-y-scroll scrollbar">
<div class="pb-2 pl-4 mr-2 text-secondary hidden" data-list-filter-target="emptyMessage">
<%= t(".no_categories") %>
</div>
<% if @categories.any? %>
<% Category::Group.for(@categories).each do |group| %>
<%= render "category/dropdowns/row", category: group.category %>
<% group.subcategories.each do |category| %>
<%= render "category/dropdowns/row", category: category %>
<% group.subcategories.each do |category| %>
<%= render "category/dropdowns/row", category: category %>
<% end %>
<% end %>
<% end %>
<% else %>
<div class="flex justify-center items-center py-12">
<div class="text-center flex flex-col items-center max-w-[500px]">
<p class="text-sm text-secondary font-normal mb-4"><%= t(".empty") %></p>
<% else %>
<div class="flex justify-center items-center py-12">
<div class="text-center flex flex-col items-center max-w-[500px]">
<p class="text-sm text-secondary font-normal mb-4"><%= t(".empty") %></p>
<%= render ButtonComponent.new(
<%= render ButtonComponent.new(
text: t(".bootstrap"),
variant: "outline",
href: bootstrap_categories_path,
method: :post,
data: { turbo_frame: :_top }) %>
</div>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
<%= render "shared/ruler", classes: "my-2" %>
<%= render "shared/ruler", classes: "my-2" %>
<div class="relative p-1.5 w-full">
<% if @transaction.category %>
<%= button_to transaction_path(@transaction.entry),
<div class="relative p-1.5 w-full">
<% if @transaction.category %>
<%= button_to transaction_path(@transaction.entry),
method: :patch,
data: { turbo_frame: dom_id(@transaction.entry) },
params: { entry: { entryable_type: "Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover" do %>
<%= icon("minus") %>
<%= icon("minus") %>
<%= t(".clear") %>
<%= t(".clear") %>
<% end %>
<% end %>
<% end %>
<% unless @transaction.transfer? %>
<%= link_to new_transaction_transfer_match_path(@transaction.entry),
<% unless @transaction.transfer? %>
<%= link_to new_transaction_transfer_match_path(@transaction.entry),
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover",
data: { turbo_frame: "modal" } do %>
<%= icon("refresh-cw") %>
<%= icon("refresh-cw") %>
<p>Match transfer/payment</p>
<p>Match transfer/payment</p>
<% end %>
<% end %>
<% end %>
<div class="flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2">
<div class="flex items-center gap-2">
<%= form_with url: transaction_path(@transaction.entry),
<div class="flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2">
<div class="flex items-center gap-2">
<%= form_with url: transaction_path(@transaction.entry),
method: :patch,
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field "entry[excluded]", value: !@transaction.entry.excluded %>
<%= f.check_box "entry[excluded]",
<%= f.hidden_field "entry[excluded]", value: !@transaction.entry.excluded %>
<%= f.check_box "entry[excluded]",
checked: @transaction.entry.excluded,
class: "checkbox checkbox--light",
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change" } %>
<% end %>
<% end %>
</div>
<p>One-time <%= @transaction.entry.amount.negative? ? "income" : "expense" %></p>
<span class="text-orange-500 ml-auto">
<%= icon("asterisk", color: "current") %>
</span>
</div>
<p>One-time <%= @transaction.entry.amount.negative? ? "income" : "expense" %></p>
<span class="text-orange-500 ml-auto">
<%= icon("asterisk", color: "current") %>
</span>
</div>
</div>
</div>
<% end %>
<% end %>

View File

@@ -13,12 +13,12 @@
<%= render MenuComponent.new(icon_vertical: true) do |menu| %>
<% menu.with_item(
variant: "link",
text: "Edit chat title",
href: edit_chat_path(chat, ctx: "list"),
icon: "pencil",
variant: "link",
text: "Edit chat title",
href: edit_chat_path(chat, ctx: "list"),
icon: "pencil",
frame: dom_id(chat, "title")) %>
<% menu.with_item(
variant: "button",
text: "Delete chat",

View File

@@ -1,7 +1,7 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector",
path: new_credit_card_path(return_to: params[:return_to]),
show_us_link: @show_us_link,
<%= render "accounts/new/method_selector",
path: new_credit_card_path(return_to: params[:return_to]),
show_us_link: @show_us_link,
show_eu_link: @show_eu_link,
accountable_type: "CreditCard" %>
<% else %>

View File

@@ -1,7 +1,7 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector",
path: new_crypto_path(return_to: params[:return_to]),
show_us_link: @show_us_link,
<%= render "accounts/new/method_selector",
path: new_crypto_path(return_to: params[:return_to]),
show_us_link: @show_us_link,
show_eu_link: @show_eu_link,
accountable_type: "Crypto" %>
<% else %>

View File

@@ -1,7 +1,7 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector",
path: new_depository_path(return_to: params[:return_to]),
show_us_link: @show_us_link,
<%= render "accounts/new/method_selector",
path: new_depository_path(return_to: params[:return_to]),
show_us_link: @show_us_link,
show_eu_link: @show_eu_link,
accountable_type: "Depository" %>
<% else %>

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