Compare commits
51 Commits
zachgoll/p
...
zachgoll/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64a43d40d1 | ||
|
|
fcf14f5f27 | ||
|
|
63d8114b05 | ||
|
|
c003e8c6ed | ||
|
|
ab1c17ea14 | ||
|
|
1aae00f586 | ||
|
|
7aca5a2277 | ||
|
|
8296e10246 | ||
|
|
9336719242 | ||
|
|
cba0bdf0e2 | ||
|
|
404066eaa1 | ||
|
|
94202b2a6b | ||
|
|
4d3c710291 | ||
|
|
b65e4d376e | ||
|
|
fc921c0cd2 | ||
|
|
b803ddac96 | ||
|
|
13a64a1694 | ||
|
|
b900cc9272 | ||
|
|
dc505cfcff | ||
|
|
96ac1dd45f | ||
|
|
5a38159c28 | ||
|
|
38cad49d6c | ||
|
|
968cd7981a | ||
|
|
6d9bb7f0eb | ||
|
|
a5f1677f60 | ||
|
|
84b2426e54 | ||
|
|
cdad31812a | ||
|
|
5a4c955522 | ||
|
|
0d62e60da1 | ||
|
|
10ce2c8e23 | ||
|
|
dab693d74f | ||
|
|
019a0d873c | ||
|
|
9fabcf4c72 | ||
|
|
4044a8519f | ||
|
|
9afc50a146 | ||
|
|
0063921de9 | ||
|
|
1d2e7fcae0 | ||
|
|
9f6c9b4057 | ||
|
|
d05946596e | ||
|
|
a76cc2dff8 | ||
|
|
870b543640 | ||
|
|
1f8a994b4e | ||
|
|
ee9fe1b62d | ||
|
|
4f5068e7e5 | ||
|
|
e7f1506728 | ||
|
|
6f67827f14 | ||
|
|
3cc88f3e98 | ||
|
|
6dae236fe0 | ||
|
|
07ca33f2f4 | ||
|
|
fe33fe086a | ||
|
|
bf2426ce82 |
53
.cursor/rules/cursor_rules.mdc
Normal file
53
.cursor/rules/cursor_rules.mdc
Normal 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
|
||||
@@ -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.
|
||||
|
||||
72
.cursor/rules/self_improve.mdc
Normal file
72
.cursor/rules/self_improve.mdc
Normal 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.
|
||||
64
.cursor/rules/stimulus_conventions.mdc
Normal file
64
.cursor/rules/stimulus_conventions.mdc
Normal 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
87
.cursor/rules/testing.mdc
Normal 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.
|
||||
|
||||
|
||||
100
.cursor/rules/view_conventions.mdc
Normal file
100
.cursor/rules/view_conventions.mdc
Normal 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`)
|
||||
@@ -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
|
||||
# ======================================================================================================
|
||||
|
||||
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -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
37
.gitignore
vendored
@@ -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
273
CLAUDE.md
Normal 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
19
Gemfile
@@ -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
|
||||
|
||||
|
||||
144
Gemfile.lock
144
Gemfile.lock
@@ -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
|
||||
|
||||
10
README.md
10
README.md
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
59
app/controllers/api/v1/accounts_controller.rb
Normal file
59
app/controllers/api/v1/accounts_controller.rb
Normal 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
|
||||
210
app/controllers/api/v1/auth_controller.rb
Normal file
210
app/controllers/api/v1/auth_controller.rb
Normal 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
|
||||
279
app/controllers/api/v1/base_controller.rb
Normal file
279
app/controllers/api/v1/base_controller.rb
Normal 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
|
||||
84
app/controllers/api/v1/chats_controller.rb
Normal file
84
app/controllers/api/v1/chats_controller.rb
Normal 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
|
||||
55
app/controllers/api/v1/messages_controller.rb
Normal file
55
app/controllers/api/v1/messages_controller.rb
Normal 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
|
||||
47
app/controllers/api/v1/test_controller.rb
Normal file
47
app/controllers/api/v1/test_controller.rb
Normal 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
|
||||
327
app/controllers/api/v1/transactions_controller.rb
Normal file
327
app/controllers/api/v1/transactions_controller.rb
Normal 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
|
||||
38
app/controllers/api/v1/usage_controller.rb
Normal file
38
app/controllers/api/v1/usage_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
61
app/controllers/settings/api_keys_controller.rb
Normal file
61
app/controllers/settings/api_keys_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
42
app/javascript/controllers/turbo_frame_timeout_controller.js
Normal file
42
app/javascript/controllers/turbo_frame_timeout_controller.js
Normal 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>
|
||||
`
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -2,6 +2,8 @@ class SecurityHealthCheckJob < ApplicationJob
|
||||
queue_as :scheduled
|
||||
|
||||
def perform
|
||||
return if Rails.env.development?
|
||||
|
||||
Security::HealthChecker.check_all
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
94
app/models/api_key.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
61
app/models/balance_sheet/account_group.rb
Normal file
61
app/models/balance_sheet/account_group.rb
Normal 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
|
||||
63
app/models/balance_sheet/account_totals.rb
Normal file
63
app/models/balance_sheet/account_totals.rb
Normal 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
|
||||
61
app/models/balance_sheet/classification_group.rb
Normal file
61
app/models/balance_sheet/classification_group.rb
Normal 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
|
||||
38
app/models/balance_sheet/net_worth_series_builder.rb
Normal file
38
app/models/balance_sheet/net_worth_series_builder.rb
Normal 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
|
||||
35
app/models/balance_sheet/sync_status_monitor.rb
Normal file
35
app/models/balance_sheet/sync_status_monitor.rb
Normal 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
|
||||
@@ -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,
|
||||
|
||||
28
app/models/demo/data_cleaner.rb
Normal file
28
app/models/demo/data_cleaner.rb
Normal 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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
55
app/models/mobile_device.rb
Normal file
55
app/models/mobile_device.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
113
app/models/trade/create_form.rb
Normal file
113
app/models/trade/create_form.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
85
app/models/transfer/creator.rb
Normal file
85
app/models/transfer/creator.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
85
app/services/api_rate_limiter.rb
Normal file
85
app/services/api_rate_limiter.rb
Normal 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
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
17
app/views/api/v1/accounts/index.json.jbuilder
Normal file
17
app/views/api/v1/accounts/index.json.jbuilder
Normal 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
|
||||
7
app/views/api/v1/chats/_chat.json.jbuilder
Normal file
7
app/views/api/v1/chats/_chat.json.jbuilder
Normal 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
|
||||
18
app/views/api/v1/chats/index.json.jbuilder
Normal file
18
app/views/api/v1/chats/index.json.jbuilder
Normal 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
|
||||
33
app/views/api/v1/chats/show.json.jbuilder
Normal file
33
app/views/api/v1/chats/show.json.jbuilder
Normal 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
|
||||
16
app/views/api/v1/messages/show.json.jbuilder
Normal file
16
app/views/api/v1/messages/show.json.jbuilder
Normal 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
|
||||
76
app/views/api/v1/transactions/_transaction.json.jbuilder
Normal file
76
app/views/api/v1/transactions/_transaction.json.jbuilder
Normal 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
|
||||
12
app/views/api/v1/transactions/index.json.jbuilder
Normal file
12
app/views/api/v1/transactions/index.json.jbuilder
Normal 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
|
||||
3
app/views/api/v1/transactions/show.json.jbuilder
Normal file
3
app/views/api/v1/transactions/show.json.jbuilder
Normal file
@@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.partial! "transaction", transaction: @transaction
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user