Compare commits
110 Commits
zachgoll/p
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
224f21354a | ||
|
|
3fb379d140 | ||
|
|
d90d35d97b | ||
|
|
5baf258a32 | ||
|
|
bacab94a1b | ||
|
|
7698ec03b9 | ||
|
|
0329a5f211 | ||
|
|
b7c56e2fb7 | ||
|
|
764164cf57 | ||
|
|
ef49268278 | ||
|
|
527a6128b6 | ||
|
|
32ec57146e | ||
|
|
f7f6ebb091 | ||
|
|
3f92fe0f6f | ||
|
|
da2045dbd8 | ||
|
|
347c0a7906 | ||
|
|
321a343df4 | ||
|
|
e8eb32d2ae | ||
|
|
ab6fdbbb68 | ||
|
|
d5b147f2cd | ||
|
|
8c97c9d31a | ||
|
|
3eea5a9891 | ||
|
|
52333e3fa6 | ||
|
|
89cc64418e | ||
|
|
c1d98fe73b | ||
|
|
9110ab27d2 | ||
|
|
afbfb474c2 | ||
|
|
fe8aebe920 | ||
|
|
188126d402 | ||
|
|
1a2d973f4b | ||
|
|
a91441bcc8 | ||
|
|
8d0c1c5a56 | ||
|
|
e848db2aa1 | ||
|
|
e7043328e4 | ||
|
|
d77c683d59 | ||
|
|
aaf24e1309 | ||
|
|
f9b131a5db | ||
|
|
a63d36d10c | ||
|
|
662f2c04ce | ||
|
|
ba7e8d3893 | ||
|
|
65329b333d | ||
|
|
0974783a6b | ||
|
|
48f792c20e | ||
|
|
869462a9a5 | ||
|
|
e4a82d85e8 | ||
|
|
18148acd69 | ||
|
|
8db95623cf | ||
|
|
e60b5df442 | ||
|
|
f3ab4a27ee | ||
|
|
4b50acff2b | ||
|
|
637d630388 | ||
|
|
72a0f87a9c | ||
|
|
cea49d5038 | ||
|
|
c0617f74cd | ||
|
|
653decbc0b | ||
|
|
1cfa6cfca8 | ||
|
|
e809335a47 | ||
|
|
956008acbf | ||
|
|
8b56262573 | ||
|
|
615912040c | ||
|
|
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
|
||||
20
Gemfile
20
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.11"
|
||||
|
||||
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"
|
||||
@@ -63,6 +72,7 @@ gem "plaid"
|
||||
gem "rotp", "~> 6.3"
|
||||
gem "rqrcode", "~> 3.0"
|
||||
gem "activerecord-import"
|
||||
gem "rubyzip", "~> 2.3"
|
||||
|
||||
# State machines
|
||||
gem "aasm"
|
||||
@@ -80,6 +90,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 +101,8 @@ group :development do
|
||||
gem "web-console"
|
||||
gem "faker"
|
||||
gem "benchmark-ips"
|
||||
gem "stackprof"
|
||||
gem "derailed_benchmarks"
|
||||
gem "foreman"
|
||||
end
|
||||
|
||||
|
||||
169
Gemfile.lock
169
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,11 +118,11 @@ 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)
|
||||
brakeman (7.0.2)
|
||||
brakeman (7.1.0)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
@@ -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)
|
||||
debug (1.11.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
|
||||
@@ -172,17 +192,17 @@ GEM
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
event_stream_parser (1.0.0)
|
||||
faker (3.5.1)
|
||||
faker (3.5.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.13.1)
|
||||
faraday (2.13.2)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-multipart (1.1.0)
|
||||
faraday-multipart (1.1.1)
|
||||
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-retry (2.3.2)
|
||||
faraday (~> 2.0)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
@@ -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,9 +268,12 @@ 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)
|
||||
jwt (2.10.1)
|
||||
json (2.12.2)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.1.1)
|
||||
@@ -273,7 +301,7 @@ GEM
|
||||
loofah (2.24.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
lookbook (2.3.9)
|
||||
lookbook (2.3.11)
|
||||
activemodel
|
||||
css_parser
|
||||
htmlbeautifier (~> 1.3)
|
||||
@@ -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)
|
||||
@@ -333,14 +364,14 @@ GEM
|
||||
octokit (10.0.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
ostruct (0.6.1)
|
||||
pagy (9.3.4)
|
||||
ostruct (0.6.2)
|
||||
pagy (9.3.5)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
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,19 +442,19 @@ 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)
|
||||
rbs (3.9.4)
|
||||
logger
|
||||
rdoc (6.14.0)
|
||||
rdoc (6.14.2)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.1)
|
||||
redis (5.4.0)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.24.0)
|
||||
redis-client (0.25.0)
|
||||
connection_pool
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.1)
|
||||
@@ -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.34.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.26.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.24.0)
|
||||
sentry-ruby (5.24.0)
|
||||
sentry-ruby (~> 5.26.0)
|
||||
sentry-ruby (5.26.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sentry-sidekiq (5.24.0)
|
||||
sentry-ruby (~> 5.24.0)
|
||||
sentry-sidekiq (5.26.0)
|
||||
sentry-ruby (~> 5.26.0)
|
||||
sidekiq (>= 3.0)
|
||||
sidekiq (8.0.3)
|
||||
sidekiq (8.0.5)
|
||||
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.3.0)
|
||||
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.11)
|
||||
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
|
||||
@@ -633,6 +672,7 @@ DEPENDENCIES
|
||||
rubocop-rails-omakase
|
||||
ruby-lsp-rails
|
||||
ruby-openai
|
||||
rubyzip (~> 2.3)
|
||||
selenium-webdriver
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
@@ -641,6 +681,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;
|
||||
|
||||
@@ -334,6 +334,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* New form field structure components */
|
||||
.form-field__header {
|
||||
@apply flex items-center justify-between gap-2;
|
||||
}
|
||||
|
||||
.form-field__body {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.form-field__actions {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
@apply block text-xs text-secondary peer-disabled:text-subdued;
|
||||
}
|
||||
@@ -347,10 +360,6 @@
|
||||
@apply transition-opacity duration-300;
|
||||
@apply placeholder:text-subdued;
|
||||
|
||||
&select {
|
||||
@apply pr-8;
|
||||
}
|
||||
|
||||
@variant theme-dark {
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
@@ -358,6 +367,14 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select.form-field__input {
|
||||
@apply pr-10 appearance-none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right -0.15rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.form-field__radio {
|
||||
@apply text-primary;
|
||||
@@ -425,7 +442,5 @@
|
||||
@variant theme-dark {
|
||||
fill: var(--color-white);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
7
app/components/DS/alert.html.erb
Normal file
7
app/components/DS/alert.html.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="<%= container_classes %>">
|
||||
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0" %>
|
||||
|
||||
<div class="flex-1 text-sm">
|
||||
<%= message %>
|
||||
</div>
|
||||
</div>
|
||||
52
app/components/DS/alert.rb
Normal file
52
app/components/DS/alert.rb
Normal file
@@ -0,0 +1,52 @@
|
||||
class DS::Alert < DesignSystemComponent
|
||||
def initialize(message:, variant: :info)
|
||||
@message = message
|
||||
@variant = variant
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :message, :variant
|
||||
|
||||
def container_classes
|
||||
base_classes = "flex items-start gap-3 p-4 rounded-lg border"
|
||||
|
||||
variant_classes = case variant
|
||||
when :info
|
||||
"bg-blue-50 text-blue-700 border-blue-200 theme-dark:bg-blue-900/20 theme-dark:text-blue-400 theme-dark:border-blue-800"
|
||||
when :success
|
||||
"bg-green-50 text-green-700 border-green-200 theme-dark:bg-green-900/20 theme-dark:text-green-400 theme-dark:border-green-800"
|
||||
when :warning
|
||||
"bg-yellow-50 text-yellow-700 border-yellow-200 theme-dark:bg-yellow-900/20 theme-dark:text-yellow-400 theme-dark:border-yellow-800"
|
||||
when :error, :destructive
|
||||
"bg-red-50 text-red-700 border-red-200 theme-dark:bg-red-900/20 theme-dark:text-red-400 theme-dark:border-red-800"
|
||||
end
|
||||
|
||||
"#{base_classes} #{variant_classes}"
|
||||
end
|
||||
|
||||
def icon_name
|
||||
case variant
|
||||
when :info
|
||||
"info"
|
||||
when :success
|
||||
"check-circle"
|
||||
when :warning
|
||||
"alert-triangle"
|
||||
when :error, :destructive
|
||||
"x-circle"
|
||||
end
|
||||
end
|
||||
|
||||
def icon_color
|
||||
case variant
|
||||
when :success
|
||||
"success"
|
||||
when :warning
|
||||
"warning"
|
||||
when :error, :destructive
|
||||
"destructive"
|
||||
else
|
||||
"blue-600"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
<%= container do %>
|
||||
<% if icon && (icon_position != :right) %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color, class: icon_classes) %>
|
||||
<% end %>
|
||||
|
||||
<% unless icon_only? %>
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# An extension to `button_to` helper. All options are passed through to the `button_to` helper with some additional
|
||||
# options available.
|
||||
class ButtonComponent < ButtonishComponent
|
||||
class DS::Button < DS::Buttonish
|
||||
attr_reader :confirm
|
||||
|
||||
def initialize(confirm: nil, **opts)
|
||||
@@ -1,11 +1,11 @@
|
||||
class ButtonishComponent < ViewComponent::Base
|
||||
class DS::Buttonish < DesignSystemComponent
|
||||
VARIANTS = {
|
||||
primary: {
|
||||
container_classes: "text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400",
|
||||
icon_classes: "fg-inverse"
|
||||
},
|
||||
secondary: {
|
||||
container_classes: "text-secondary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600",
|
||||
container_classes: "text-primary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600",
|
||||
icon_classes: "fg-primary"
|
||||
},
|
||||
destructive: {
|
||||
@@ -71,7 +71,7 @@ class ButtonishComponent < ViewComponent::Base
|
||||
end
|
||||
|
||||
def call
|
||||
raise NotImplementedError, "ButtonishComponent is an abstract class and cannot be instantiated directly."
|
||||
raise NotImplementedError, "Buttonish is an abstract class and cannot be instantiated directly."
|
||||
end
|
||||
|
||||
def container_classes(override_classes = nil)
|
||||
@@ -1,7 +1,7 @@
|
||||
<%= wrapper_element do %>
|
||||
<%= tag.dialog class: "w-full h-full bg-transparent theme-dark:backdrop:bg-alpha-black-900 backdrop:bg-overlay #{drawer? ? "lg:p-3" : "lg:p-1"}", **merged_opts do %>
|
||||
<%= tag.div class: dialog_outer_classes do %>
|
||||
<%= tag.div class: dialog_inner_classes, data: { dialog_target: "content" } do %>
|
||||
<%= tag.div class: dialog_inner_classes, data: { DS__dialog_target: "content" } do %>
|
||||
<div class="grow overflow-y-auto py-4 space-y-4 flex flex-col">
|
||||
<% if header? %>
|
||||
<%= header %>
|
||||
@@ -1,9 +1,9 @@
|
||||
class DialogComponent < ViewComponent::Base
|
||||
class DS::Dialog < DesignSystemComponent
|
||||
renders_one :header, ->(title: nil, subtitle: nil, hide_close_icon: false, **opts, &block) do
|
||||
content_tag(:header, class: "px-4 flex flex-col gap-2", **opts) do
|
||||
title_div = content_tag(:div, class: "flex items-center justify-between gap-2") do
|
||||
title = content_tag(:h2, title, class: class_names("font-medium text-primary", drawer? ? "text-lg" : "")) if title
|
||||
close_icon = render ButtonComponent.new(variant: "icon", class: "ml-auto", icon: "x", tabindex: "-1", data: { action: "dialog#close" }) unless hide_close_icon
|
||||
close_icon = render DS::Button.new(variant: "icon", class: "ml-auto", icon: "x", tabindex: "-1", data: { action: "DS--dialog#close" }) unless hide_close_icon
|
||||
safe_join([ title, close_icon ].compact)
|
||||
end
|
||||
|
||||
@@ -19,16 +19,16 @@ class DialogComponent < ViewComponent::Base
|
||||
|
||||
renders_many :actions, ->(cancel_action: false, **button_opts) do
|
||||
merged_opts = if cancel_action
|
||||
button_opts.merge(type: "button", data: { action: "modal#close" })
|
||||
button_opts.merge(type: "button", data: { action: "DS--dialog#close" })
|
||||
else
|
||||
button_opts
|
||||
end
|
||||
|
||||
render ButtonComponent.new(**merged_opts)
|
||||
render DS::Button.new(**merged_opts)
|
||||
end
|
||||
|
||||
renders_many :sections, ->(title:, **disclosure_opts, &block) do
|
||||
render DisclosureComponent.new(title: title, align: :right, **disclosure_opts) do
|
||||
render DS::Disclosure.new(title: title, align: :right, **disclosure_opts) do
|
||||
block.call
|
||||
end
|
||||
end
|
||||
@@ -99,11 +99,11 @@ class DialogComponent < ViewComponent::Base
|
||||
merged_opts = opts.dup
|
||||
data = merged_opts.delete(:data) || {}
|
||||
|
||||
data[:controller] = [ "dialog", "hotkey", data[:controller] ].compact.join(" ")
|
||||
data[:dialog_auto_open_value] = auto_open
|
||||
data[:dialog_reload_on_close_value] = reload_on_close
|
||||
data[:action] = [ "mousedown->dialog#clickOutside", data[:action] ].compact.join(" ")
|
||||
data[:hotkey] = "esc:dialog#close"
|
||||
data[:controller] = [ "DS--dialog", "hotkey", data[:controller] ].compact.join(" ")
|
||||
data[:DS__dialog_auto_open_value] = auto_open
|
||||
data[:DS__dialog_reload_on_close_value] = reload_on_close
|
||||
data[:action] = [ "mousedown->DS--dialog#clickOutside", data[:action] ].compact.join(" ")
|
||||
data[:hotkey] = "esc:DS--dialog#close"
|
||||
merged_opts[:data] = data
|
||||
|
||||
merged_opts
|
||||
27
app/components/DS/disclosure.html.erb
Normal file
27
app/components/DS/disclosure.html.erb
Normal file
@@ -0,0 +1,27 @@
|
||||
<details class="group" <%= "open" if open %>>
|
||||
<%= tag.summary class: class_names(
|
||||
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface"
|
||||
) do %>
|
||||
<% if summary_content? %>
|
||||
<%= summary_content %>
|
||||
<% else %>
|
||||
<div class="flex items-center gap-3">
|
||||
<% if align == :left %>
|
||||
<%= helpers.icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.span class: class_names("font-medium", align == :left ? "text-sm text-primary" : "text-xs uppercase text-secondary") do %>
|
||||
<%= title %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if align == :right %>
|
||||
<%= helpers.icon "chevron-down", class: "group-open:transform group-open:rotate-180" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-2">
|
||||
<%= content %>
|
||||
</div>
|
||||
</details>
|
||||
@@ -1,9 +1,9 @@
|
||||
class DisclosureComponent < ViewComponent::Base
|
||||
class DS::Disclosure < DesignSystemComponent
|
||||
renders_one :summary_content
|
||||
|
||||
attr_reader :title, :align, :open, :opts
|
||||
|
||||
def initialize(title:, align: "right", open: false, **opts)
|
||||
def initialize(title: nil, align: "right", open: false, **opts)
|
||||
@title = title
|
||||
@align = align.to_sym
|
||||
@open = open
|
||||
@@ -1,4 +1,4 @@
|
||||
class FilledIconComponent < ViewComponent::Base
|
||||
class DS::FilledIcon < DesignSystemComponent
|
||||
attr_reader :icon, :text, :hex_color, :size, :rounded, :variant
|
||||
|
||||
VARIANTS = %i[default text surface container inverse].freeze
|
||||
@@ -1,7 +1,6 @@
|
||||
<%= link_to href, **merged_opts do %>
|
||||
<% if icon && (icon_position != "right") %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||
|
||||
<% end %>
|
||||
|
||||
<% unless icon_only? %>
|
||||
@@ -10,6 +9,5 @@
|
||||
|
||||
<% if icon && icon_position == "right" %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -1,6 +1,6 @@
|
||||
# An extension to `link_to` helper. All options are passed through to the `link_to` helper with some additional
|
||||
# options available.
|
||||
class LinkComponent < ButtonishComponent
|
||||
class DS::Link < DS::Buttonish
|
||||
attr_reader :frame
|
||||
|
||||
VARIANTS = VARIANTS.reverse_merge(
|
||||
@@ -1,17 +1,17 @@
|
||||
<%= tag.div data: { controller: "menu", menu_placement_value: placement, menu_offset_value: offset, testid: testid } do %>
|
||||
<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, testid: testid } do %>
|
||||
<% if variant == :icon %>
|
||||
<%= render ButtonComponent.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { menu_target: "button" }) %>
|
||||
<%= render DS::Button.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { DS__menu_target: "button" }) %>
|
||||
<% elsif variant == :button %>
|
||||
<%= button %>
|
||||
<% elsif variant == :avatar %>
|
||||
<button data-menu-target="button">
|
||||
<button data-DS--menu-target="button">
|
||||
<div class="w-9 h-9 cursor-pointer">
|
||||
<%= render "settings/user_avatar", avatar_url: avatar_url, initials: initials %>
|
||||
</div>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<div data-menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
|
||||
<div data-DS--menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
|
||||
<div class="mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg">
|
||||
<%= header %>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class MenuComponent < ViewComponent::Base
|
||||
class DS::Menu < DesignSystemComponent
|
||||
attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid
|
||||
|
||||
renders_one :button, ->(**button_options, &block) do
|
||||
options_with_target = button_options.merge(data: { menu_target: "button" })
|
||||
options_with_target = button_options.merge(data: { DS__menu_target: "button" })
|
||||
|
||||
if block
|
||||
content_tag(:button, **options_with_target, &block)
|
||||
else
|
||||
ButtonComponent.new(**options_with_target)
|
||||
DS::Button.new(**options_with_target)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,7 @@ class MenuComponent < ViewComponent::Base
|
||||
|
||||
renders_one :custom_content
|
||||
|
||||
renders_many :items, MenuItemComponent
|
||||
renders_many :items, DS::MenuItem
|
||||
|
||||
VARIANTS = %i[icon button avatar].freeze
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class MenuItemComponent < ViewComponent::Base
|
||||
class DS::MenuItem < DesignSystemComponent
|
||||
VARIANTS = %i[link button divider].freeze
|
||||
|
||||
attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :frame, :opts
|
||||
@@ -1,4 +1,4 @@
|
||||
class TabComponent < ViewComponent::Base
|
||||
class DS::Tab < DesignSystemComponent
|
||||
attr_reader :id, :label
|
||||
|
||||
def initialize(id:, label:)
|
||||
18
app/components/DS/tabs.html.erb
Normal file
18
app/components/DS/tabs.html.erb
Normal file
@@ -0,0 +1,18 @@
|
||||
<%= tag.div data: {
|
||||
controller: "DS--tabs",
|
||||
testid: testid,
|
||||
DS__tabs_session_key_value: session_key,
|
||||
DS__tabs_url_param_key_value: url_param_key,
|
||||
DS__tabs_nav_btn_active_class: active_btn_classes,
|
||||
DS__tabs_nav_btn_inactive_class: inactive_btn_classes
|
||||
} do %>
|
||||
<% if unstyled? %>
|
||||
<%= content %>
|
||||
<% else %>
|
||||
<%= nav %>
|
||||
|
||||
<% panels.each do |panel| %>
|
||||
<%= panel %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -1,6 +1,6 @@
|
||||
class TabsComponent < ViewComponent::Base
|
||||
class DS::Tabs < DesignSystemComponent
|
||||
renders_one :nav, ->(classes: nil) do
|
||||
Tabs::NavComponent.new(
|
||||
DS::Tabs::Nav.new(
|
||||
active_tab: active_tab,
|
||||
active_btn_classes: active_btn_classes,
|
||||
inactive_btn_classes: inactive_btn_classes,
|
||||
@@ -13,7 +13,7 @@ class TabsComponent < ViewComponent::Base
|
||||
content_tag(
|
||||
:div,
|
||||
class: ("hidden" unless tab_id == active_tab),
|
||||
data: { id: tab_id, tabs_target: "panel" },
|
||||
data: { id: tab_id, DS__tabs_target: "panel" },
|
||||
&block
|
||||
)
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Tabs::NavComponent < ViewComponent::Base
|
||||
class DS::Tabs::Nav < DesignSystemComponent
|
||||
erb_template <<~ERB
|
||||
<%= tag.nav class: classes do %>
|
||||
<% btns.each do |btn| %>
|
||||
@@ -12,7 +12,7 @@ class Tabs::NavComponent < ViewComponent::Base
|
||||
:button, label, id: id,
|
||||
type: "button",
|
||||
class: class_names(btn_classes, id == active_tab ? active_btn_classes : inactive_btn_classes, classes),
|
||||
data: { id: id, action: "tabs#show", tabs_target: "navBtn" },
|
||||
data: { id: id, action: "DS--tabs#show", DS__tabs_target: "navBtn" },
|
||||
&block
|
||||
)
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Tabs::PanelComponent < ViewComponent::Base
|
||||
class DS::Tabs::Panel < DesignSystemComponent
|
||||
attr_reader :tab_id
|
||||
|
||||
def initialize(tab_id:)
|
||||
@@ -1,4 +1,4 @@
|
||||
class ToggleComponent < ViewComponent::Base
|
||||
class DS::Toggle < DesignSystemComponent
|
||||
attr_reader :id, :name, :checked, :disabled, :checked_value, :unchecked_value, :opts
|
||||
|
||||
def initialize(id:, name: nil, checked: false, disabled: false, checked_value: "1", unchecked_value: "0", **opts)
|
||||
9
app/components/DS/tooltip.html.erb
Normal file
9
app/components/DS/tooltip.html.erb
Normal file
@@ -0,0 +1,9 @@
|
||||
<span data-controller="DS--tooltip" data-DS--tooltip-placement-value="<%= placement %>" data-DS--tooltip-offset-value="<%= offset %>" data-DS--tooltip-cross-axis-value="<%= cross_axis %>" class="inline-flex">
|
||||
<%= helpers.icon icon_name, size: size, color: color %>
|
||||
|
||||
<div role="tooltip" data-DS--tooltip-target="tooltip" class="hidden absolute z-50 bg-gray-700 text-sm px-1.5 py-1 rounded-md">
|
||||
<div class="fg-inverse font-normal max-w-[200px]">
|
||||
<%= tooltip_content %>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
17
app/components/DS/tooltip.rb
Normal file
17
app/components/DS/tooltip.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
class DS::Tooltip < ApplicationComponent
|
||||
attr_reader :placement, :offset, :cross_axis, :icon_name, :size, :color
|
||||
|
||||
def initialize(text: nil, placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default")
|
||||
@text = text
|
||||
@placement = placement
|
||||
@offset = offset
|
||||
@cross_axis = cross_axis
|
||||
@icon_name = icon
|
||||
@size = size
|
||||
@color = color
|
||||
end
|
||||
|
||||
def tooltip_content
|
||||
content? ? content : @text
|
||||
end
|
||||
end
|
||||
87
app/components/DS/tooltip_controller.js
Normal file
87
app/components/DS/tooltip_controller.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["tooltip"];
|
||||
static values = {
|
||||
placement: { type: String, default: "top" },
|
||||
offset: { type: Number, default: 10 },
|
||||
crossAxis: { type: Number, default: 0 },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this._cleanup = null;
|
||||
this.boundUpdate = this.update.bind(this);
|
||||
this.addEventListeners();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.removeEventListeners();
|
||||
this.stopAutoUpdate();
|
||||
}
|
||||
|
||||
addEventListeners() {
|
||||
this.element.addEventListener("mouseenter", this.show);
|
||||
this.element.addEventListener("mouseleave", this.hide);
|
||||
}
|
||||
|
||||
removeEventListeners() {
|
||||
this.element.removeEventListener("mouseenter", this.show);
|
||||
this.element.removeEventListener("mouseleave", this.hide);
|
||||
}
|
||||
|
||||
show = () => {
|
||||
this.tooltipTarget.classList.remove("hidden");
|
||||
this.startAutoUpdate();
|
||||
this.update();
|
||||
};
|
||||
|
||||
hide = () => {
|
||||
this.tooltipTarget.classList.add("hidden");
|
||||
this.stopAutoUpdate();
|
||||
};
|
||||
|
||||
startAutoUpdate() {
|
||||
if (!this._cleanup) {
|
||||
const reference = this.element.querySelector("[data-icon]");
|
||||
this._cleanup = autoUpdate(
|
||||
reference || this.element,
|
||||
this.tooltipTarget,
|
||||
this.boundUpdate
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
stopAutoUpdate() {
|
||||
if (this._cleanup) {
|
||||
this._cleanup();
|
||||
this._cleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
const reference = this.element.querySelector("[data-icon]");
|
||||
computePosition(reference || this.element, this.tooltipTarget, {
|
||||
placement: this.placementValue,
|
||||
middleware: [
|
||||
offset({
|
||||
mainAxis: this.offsetValue,
|
||||
crossAxis: this.crossAxisValue,
|
||||
}),
|
||||
flip(),
|
||||
shift({ padding: 5 }),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
Object.assign(this.tooltipTarget.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
42
app/components/UI/account/activity_date.html.erb
Normal file
42
app/components/UI/account/activity_date.html.erb
Normal file
@@ -0,0 +1,42 @@
|
||||
<%= tag.div id: id, data: { bulk_select_target: "group" }, class: "bg-container-inset rounded-xl p-1 w-full" do %>
|
||||
<details class="group">
|
||||
<summary>
|
||||
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-secondary">
|
||||
<div class="flex pl-0.5 items-center gap-4">
|
||||
<%= check_box_tag "#{date}_entries_selection",
|
||||
class: ["checkbox checkbox--light", "hidden": entries.size == 0],
|
||||
id: "selection_entry_#{date}",
|
||||
data: { action: "bulk-select#toggleGroupSelection" } %>
|
||||
|
||||
<p class="uppercase space-x-1.5">
|
||||
<%= tag.span I18n.l(date, format: :long) %>
|
||||
<span>·</span>
|
||||
<%= tag.span entries.size %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium"><%= end_balance_money.format %></span>
|
||||
<%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
|
||||
</div>
|
||||
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div class="p-4">
|
||||
<% if balance %>
|
||||
<%= render UI::Account::BalanceReconciliation.new(balance: balance, account: account) %>
|
||||
<% else %>
|
||||
<p class="text-sm text-secondary">No balance data available for this date</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="bg-container shadow-border-xs rounded-lg">
|
||||
<% entries.each do |entry| %>
|
||||
<%= render entry, view_ctx: "account" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
31
app/components/UI/account/activity_date.rb
Normal file
31
app/components/UI/account/activity_date.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
class UI::Account::ActivityDate < ApplicationComponent
|
||||
attr_reader :account, :data
|
||||
|
||||
delegate :date, :entries, :balance, :transfers, to: :data
|
||||
|
||||
def initialize(account:, data:)
|
||||
@account = account
|
||||
@data = data
|
||||
end
|
||||
|
||||
def id
|
||||
dom_id(account, "entries_#{date}")
|
||||
end
|
||||
|
||||
def broadcast_channel
|
||||
account
|
||||
end
|
||||
|
||||
def end_balance_money
|
||||
balance&.end_balance_money || Money.new(0, account.currency)
|
||||
end
|
||||
|
||||
def broadcast_refresh!
|
||||
Turbo::StreamsChannel.broadcast_replace_to(
|
||||
broadcast_channel,
|
||||
target: id,
|
||||
renderable: self,
|
||||
layout: false
|
||||
)
|
||||
end
|
||||
end
|
||||
94
app/components/UI/account/activity_feed.html.erb
Normal file
94
app/components/UI/account/activity_feed.html.erb
Normal file
@@ -0,0 +1,94 @@
|
||||
<%= turbo_frame_tag dom_id(account, "entries") do %>
|
||||
<div class="bg-container p-5 shadow-border-xs rounded-xl">
|
||||
<div class="flex items-center justify-between mb-4" data-testid="activity-menu">
|
||||
<%= tag.h2 "Activity", class: "font-medium text-lg" %>
|
||||
|
||||
<% if account.manual? %>
|
||||
<%= render DS::Menu.new(variant: "button") do |menu| %>
|
||||
<% menu.with_button(text: "New", variant: "secondary", icon: "plus") %>
|
||||
|
||||
<% menu.with_item(
|
||||
variant: "link",
|
||||
text: "New balance",
|
||||
icon: "circle-dollar-sign",
|
||||
href: new_valuation_path(account_id: account.id),
|
||||
data: { turbo_frame: :modal }) %>
|
||||
|
||||
<% unless account.crypto? %>
|
||||
<% menu.with_item(
|
||||
variant: "link",
|
||||
text: "New transaction",
|
||||
icon: "credit-card",
|
||||
href: account.investment? ? new_trade_path(account_id: account.id) : new_transaction_path(account_id: account.id),
|
||||
data: { turbo_frame: :modal }) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form_with url: account_path(account),
|
||||
id: "entries-search",
|
||||
scope: :q,
|
||||
method: :get,
|
||||
data: { controller: "auto-submit-form" } do |form| %>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="grow">
|
||||
<div class="flex items-center px-3 py-2 gap-2 border border-secondary rounded-lg focus-within:ring-gray-100 focus-within:border-gray-900">
|
||||
<%= helpers.icon("search") %>
|
||||
|
||||
<%= hidden_field_tag :account_id, account.id %>
|
||||
|
||||
<%= form.search_field :search,
|
||||
placeholder: "Search entries by name",
|
||||
value: search,
|
||||
class: "form-field__input placeholder:text-sm placeholder:text-secondary",
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if activity_dates.empty? %>
|
||||
<p class="text-secondary text-sm p-4">No entries yet</p>
|
||||
<% else %>
|
||||
<%= tag.div id: dom_id(account, "entries_bulk_select"),
|
||||
data: {
|
||||
controller: "bulk-select",
|
||||
bulk_select_singular_label_value: "entry",
|
||||
bulk_select_plural_label_value: "entries"
|
||||
} do %>
|
||||
<div id="entry-selection-bar" data-bulk-select-target="selectionBar" class="flex justify-center hidden">
|
||||
<%= render "entries/selection_bar" %>
|
||||
</div>
|
||||
|
||||
<div class="grid bg-container-inset rounded-xl grid-cols-12 items-center uppercase text-xs font-medium text-secondary px-5 py-3 mb-4">
|
||||
<div class="pl-0.5 col-span-8 flex items-center gap-4">
|
||||
<%= check_box_tag "selection_entry",
|
||||
class: "checkbox checkbox--light",
|
||||
data: { action: "bulk-select#togglePageSelection" } %>
|
||||
<p>Date</p>
|
||||
</div>
|
||||
|
||||
<%= tag.p "Amount", class: "col-span-4 justify-self-end" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="space-y-4">
|
||||
<% activity_dates.each do |activity_date_data| %>
|
||||
<%= render UI::Account::ActivityDate.new(
|
||||
account: account,
|
||||
data: activity_date_data
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-container rounded-bl-lg rounded-br-lg">
|
||||
<%= render "shared/pagination", pagy: pagy %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
35
app/components/UI/account/activity_feed.rb
Normal file
35
app/components/UI/account/activity_feed.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class UI::Account::ActivityFeed < ApplicationComponent
|
||||
attr_reader :feed_data, :pagy, :search
|
||||
|
||||
def initialize(feed_data:, pagy:, search: nil)
|
||||
@feed_data = feed_data
|
||||
@pagy = pagy
|
||||
@search = search
|
||||
end
|
||||
|
||||
def id
|
||||
dom_id(account, :activity_feed)
|
||||
end
|
||||
|
||||
def broadcast_channel
|
||||
account
|
||||
end
|
||||
|
||||
def broadcast_refresh!
|
||||
Turbo::StreamsChannel.broadcast_replace_to(
|
||||
broadcast_channel,
|
||||
target: id,
|
||||
renderable: self,
|
||||
layout: false
|
||||
)
|
||||
end
|
||||
|
||||
def activity_dates
|
||||
feed_data.entries_by_date
|
||||
end
|
||||
|
||||
private
|
||||
def account
|
||||
feed_data.account
|
||||
end
|
||||
end
|
||||
22
app/components/UI/account/balance_reconciliation.html.erb
Normal file
22
app/components/UI/account/balance_reconciliation.html.erb
Normal file
@@ -0,0 +1,22 @@
|
||||
<div class="space-y-3">
|
||||
<% reconciliation_items.each_with_index do |item, index| %>
|
||||
<% if item[:style] == :subtotal %>
|
||||
<hr class="border border-primary">
|
||||
<% end %>
|
||||
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
<%= item[:label] %>
|
||||
<%= render DS::Tooltip.new(text: item[:tooltip], placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed <%= item[:style] == :final ? "border-primary" : "border-secondary" %>">
|
||||
<dd class="<%= item[:style] == :start || item[:style] == :final ? "font-bold" : item[:style] == :subtotal ? "font-medium" : "" %>">
|
||||
<%= item[:value].format %>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<% if item[:style] == :adjustment %>
|
||||
<hr class="border border-primary">
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
155
app/components/UI/account/balance_reconciliation.rb
Normal file
155
app/components/UI/account/balance_reconciliation.rb
Normal file
@@ -0,0 +1,155 @@
|
||||
class UI::Account::BalanceReconciliation < ApplicationComponent
|
||||
attr_reader :balance, :account
|
||||
|
||||
def initialize(balance:, account:)
|
||||
@balance = balance
|
||||
@account = account
|
||||
end
|
||||
|
||||
def reconciliation_items
|
||||
case account.accountable_type
|
||||
when "Depository", "OtherAsset", "OtherLiability"
|
||||
default_items
|
||||
when "CreditCard"
|
||||
credit_card_items
|
||||
when "Investment"
|
||||
investment_items
|
||||
when "Loan"
|
||||
loan_items
|
||||
when "Property", "Vehicle"
|
||||
asset_items
|
||||
when "Crypto"
|
||||
crypto_items
|
||||
else
|
||||
default_items
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_items
|
||||
items = [
|
||||
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The account balance at the beginning of this day", style: :start },
|
||||
{ label: "Net cash flow", value: net_cash_flow, tooltip: "Net change in balance from all transactions during the day", style: :flow }
|
||||
]
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal }
|
||||
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final account balance for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def credit_card_items
|
||||
items = [
|
||||
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The balance owed at the beginning of this day", style: :start },
|
||||
{ label: "Charges", value: balance.cash_outflows_money, tooltip: "New charges made during the day", style: :flow },
|
||||
{ label: "Payments", value: balance.cash_inflows_money * -1, tooltip: "Payments made to the card during the day", style: :flow }
|
||||
]
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all transactions", style: :subtotal }
|
||||
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final balance owed for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def investment_items
|
||||
items = [
|
||||
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The total portfolio value at the beginning of this day", style: :start }
|
||||
]
|
||||
|
||||
# Change in brokerage cash (includes deposits, withdrawals, and cash from trades)
|
||||
items << { label: "Change in brokerage cash", value: net_cash_flow, tooltip: "Net change in cash from deposits, withdrawals, and trades", style: :flow }
|
||||
|
||||
# Change in holdings from trading activity
|
||||
items << { label: "Change in holdings (buys/sells)", value: net_non_cash_flow, tooltip: "Impact on holdings from buying and selling securities", style: :flow }
|
||||
|
||||
# Market price changes
|
||||
items << { label: "Change in holdings (market price activity)", value: balance.net_market_flows_money, tooltip: "Change in holdings value from market price movements", style: :flow }
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal }
|
||||
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final portfolio value for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def loan_items
|
||||
items = [
|
||||
{ label: "Start principal", value: balance.start_balance_money, tooltip: "The principal balance at the beginning of this day", style: :start },
|
||||
{ label: "Net principal change", value: net_non_cash_flow, tooltip: "Principal payments and new borrowing during the day", style: :flow }
|
||||
]
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End principal", value: end_balance_before_adjustments, tooltip: "The calculated principal after all transactions", style: :subtotal }
|
||||
items << { label: "Adjustments", value: balance.non_cash_adjustments_money, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final principal", value: balance.end_balance_money, tooltip: "The final principal balance for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def asset_items # Property/Vehicle
|
||||
items = [
|
||||
{ label: "Start value", value: balance.start_balance_money, tooltip: "The asset value at the beginning of this day", style: :start },
|
||||
{ label: "Net value change", value: net_total_flow, tooltip: "All value changes including improvements and depreciation", style: :flow }
|
||||
]
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End value", value: end_balance_before_adjustments, tooltip: "The calculated value after all changes", style: :subtotal }
|
||||
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual value adjustments or appraisals", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final value", value: balance.end_balance_money, tooltip: "The final asset value for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def crypto_items
|
||||
items = [
|
||||
{ label: "Start balance", value: balance.start_balance_money, tooltip: "The crypto holdings value at the beginning of this day", style: :start }
|
||||
]
|
||||
|
||||
items << { label: "Buys", value: balance.cash_outflows_money * -1, tooltip: "Crypto purchases during the day", style: :flow } if balance.cash_outflows != 0
|
||||
items << { label: "Sells", value: balance.cash_inflows_money, tooltip: "Crypto sales during the day", style: :flow } if balance.cash_inflows != 0
|
||||
items << { label: "Market changes", value: balance.net_market_flows_money, tooltip: "Value changes from market price movements", style: :flow } if balance.net_market_flows != 0
|
||||
|
||||
if has_adjustments?
|
||||
items << { label: "End balance", value: end_balance_before_adjustments, tooltip: "The calculated balance after all activity", style: :subtotal }
|
||||
items << { label: "Adjustments", value: total_adjustments, tooltip: "Manual reconciliations or other adjustments", style: :adjustment }
|
||||
end
|
||||
|
||||
items << { label: "Final balance", value: balance.end_balance_money, tooltip: "The final crypto holdings value for the day", style: :final }
|
||||
items
|
||||
end
|
||||
|
||||
def net_cash_flow
|
||||
balance.cash_inflows_money - balance.cash_outflows_money
|
||||
end
|
||||
|
||||
def net_non_cash_flow
|
||||
balance.non_cash_inflows_money - balance.non_cash_outflows_money
|
||||
end
|
||||
|
||||
def net_total_flow
|
||||
net_cash_flow + net_non_cash_flow + balance.net_market_flows_money
|
||||
end
|
||||
|
||||
def total_adjustments
|
||||
balance.cash_adjustments_money + balance.non_cash_adjustments_money
|
||||
end
|
||||
|
||||
def has_adjustments?
|
||||
balance.cash_adjustments != 0 || balance.non_cash_adjustments != 0
|
||||
end
|
||||
|
||||
def end_balance_before_adjustments
|
||||
balance.end_balance_money - total_adjustments
|
||||
end
|
||||
end
|
||||
58
app/components/UI/account/chart.html.erb
Normal file
58
app/components/UI/account/chart.html.erb
Normal file
@@ -0,0 +1,58 @@
|
||||
<div id="<%= dom_id(account, :chart) %>" class="bg-container shadow-border-xs rounded-xl space-y-2">
|
||||
<div class="flex justify-between flex-col-reverse lg:flex-row gap-2 px-4 pt-4 mb-2">
|
||||
<div class="space-y-2 w-full">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= tag.p title, class: "text-sm font-medium text-secondary" %>
|
||||
|
||||
<% if account.investment? %>
|
||||
<%= render "investments/value_tooltip", balance: account.balance_money, holdings: holdings_value_money, cash: account.cash_balance_money %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 items-baseline">
|
||||
<%= tag.p view_balance_money.format, class: "text-primary text-3xl font-medium truncate" %>
|
||||
|
||||
<% if converted_balance_money %>
|
||||
<%= tag.p converted_balance_money.format, class: "text-sm font-medium text-secondary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form_with url: account_path(account), method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
||||
<div class="flex items-center gap-2">
|
||||
<% if account.investment? %>
|
||||
<%= form.select :chart_view,
|
||||
[["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]],
|
||||
{ selected: view },
|
||||
class: "bg-container border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0",
|
||||
data: { "auto-submit-form-target": "auto" } %>
|
||||
<% end %>
|
||||
|
||||
<%= form.select :period,
|
||||
Period.as_options,
|
||||
{ selected: period.key },
|
||||
data: { "auto-submit-form-target": "auto" },
|
||||
class: "bg-container border border-secondary rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
|
||||
<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
|
||||
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">No data available</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
72
app/components/UI/account/chart.rb
Normal file
72
app/components/UI/account/chart.rb
Normal file
@@ -0,0 +1,72 @@
|
||||
class UI::Account::Chart < ApplicationComponent
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account:, period: nil, view: nil)
|
||||
@account = account
|
||||
@period = period
|
||||
@view = view
|
||||
end
|
||||
|
||||
def period
|
||||
@period ||= Period.last_30_days
|
||||
end
|
||||
|
||||
def holdings_value_money
|
||||
account.balance_money - account.cash_balance_money
|
||||
end
|
||||
|
||||
def view_balance_money
|
||||
case view
|
||||
when "balance"
|
||||
account.balance_money
|
||||
when "holdings_balance"
|
||||
holdings_value_money
|
||||
when "cash_balance"
|
||||
account.cash_balance_money
|
||||
end
|
||||
end
|
||||
|
||||
def title
|
||||
case account.accountable_type
|
||||
when "Investment", "Crypto"
|
||||
case view
|
||||
when "balance"
|
||||
"Total account value"
|
||||
when "holdings_balance"
|
||||
"Holdings value"
|
||||
when "cash_balance"
|
||||
"Cash value"
|
||||
end
|
||||
when "Property", "Vehicle"
|
||||
"Estimated #{account.accountable_type.humanize.downcase} value"
|
||||
when "CreditCard", "OtherLiability"
|
||||
"Debt balance"
|
||||
when "Loan"
|
||||
"Remaining principal balance"
|
||||
else
|
||||
"Balance"
|
||||
end
|
||||
end
|
||||
|
||||
def foreign_currency?
|
||||
account.currency != account.family.currency
|
||||
end
|
||||
|
||||
def converted_balance_money
|
||||
return nil unless foreign_currency?
|
||||
|
||||
account.balance_money.exchange_to(account.family.currency, fallback_rate: 1)
|
||||
end
|
||||
|
||||
def view
|
||||
@view ||= "balance"
|
||||
end
|
||||
|
||||
def series
|
||||
account.balance_series(period: period, view: view)
|
||||
end
|
||||
|
||||
def trend
|
||||
series.trend
|
||||
end
|
||||
end
|
||||
29
app/components/UI/account_page.html.erb
Normal file
29
app/components/UI/account_page.html.erb
Normal file
@@ -0,0 +1,29 @@
|
||||
<%= turbo_stream_from account %>
|
||||
|
||||
<%= turbo_frame_tag id do %>
|
||||
<%= tag.div class: "space-y-4 pb-32" do %>
|
||||
<%= render "accounts/show/header", account: account, title: title, subtitle: subtitle %>
|
||||
|
||||
<%= render UI::Account::Chart.new(account: account, period: chart_period, view: chart_view) %>
|
||||
|
||||
<div class="min-h-[800px]" data-testid="account-details">
|
||||
<% if tabs.count > 1 %>
|
||||
<%= render DS::Tabs.new(active_tab: active_tab, url_param_key: "tab") do |tabs_container| %>
|
||||
<% tabs_container.with_nav(classes: "max-w-fit") do |nav| %>
|
||||
<% tabs.each do |tab| %>
|
||||
<% nav.with_btn(id: tab, label: tab.to_s.humanize, classes: "px-6") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% tabs.each do |tab| %>
|
||||
<% tabs_container.with_panel(tab_id: tab) do %>
|
||||
<%= tab_content_for(tab) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= tab_content_for(tabs.first) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
59
app/components/UI/account_page.rb
Normal file
59
app/components/UI/account_page.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
class UI::AccountPage < ApplicationComponent
|
||||
attr_reader :account, :chart_view, :chart_period
|
||||
|
||||
renders_one :activity_feed, ->(feed_data:, pagy:, search:) { UI::Account::ActivityFeed.new(feed_data: feed_data, pagy: pagy, search: search) }
|
||||
|
||||
def initialize(account:, chart_view: nil, chart_period: nil, active_tab: nil)
|
||||
@account = account
|
||||
@chart_view = chart_view
|
||||
@chart_period = chart_period
|
||||
@active_tab = active_tab
|
||||
end
|
||||
|
||||
def id
|
||||
dom_id(account, :container)
|
||||
end
|
||||
|
||||
def broadcast_channel
|
||||
account
|
||||
end
|
||||
|
||||
def broadcast_refresh!
|
||||
Turbo::StreamsChannel.broadcast_replace_to(broadcast_channel, target: id, renderable: self, layout: false)
|
||||
end
|
||||
|
||||
def title
|
||||
account.name
|
||||
end
|
||||
|
||||
def subtitle
|
||||
return nil unless account.property?
|
||||
|
||||
account.property.address
|
||||
end
|
||||
|
||||
def active_tab
|
||||
tabs.find { |tab| tab == @active_tab&.to_sym } || tabs.first
|
||||
end
|
||||
|
||||
def tabs
|
||||
case account.accountable_type
|
||||
when "Investment"
|
||||
[ :activity, :holdings ]
|
||||
when "Property", "Vehicle", "Loan"
|
||||
[ :activity, :overview ]
|
||||
else
|
||||
[ :activity ]
|
||||
end
|
||||
end
|
||||
|
||||
def tab_content_for(tab)
|
||||
case tab
|
||||
when :activity
|
||||
activity_feed
|
||||
when :holdings, :overview
|
||||
# Accountable is responsible for implementing the partial in the correct folder
|
||||
render "#{account.accountable_type.downcase.pluralize}/tabs/#{tab}", account: account
|
||||
end
|
||||
end
|
||||
end
|
||||
4
app/components/application_component.rb
Normal file
4
app/components/application_component.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class ApplicationComponent < ViewComponent::Base
|
||||
# These don't work as expected with helpers.turbo_frame_tag, etc., so we include them here
|
||||
include Turbo::FramesHelper, Turbo::StreamsHelper
|
||||
end
|
||||
2
app/components/design_system_component.rb
Normal file
2
app/components/design_system_component.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
class DesignSystemComponent < ViewComponent::Base
|
||||
end
|
||||
@@ -1,25 +0,0 @@
|
||||
<details class="group" <%= "open" if open %>>
|
||||
<%= tag.summary class: class_names(
|
||||
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface"
|
||||
) do %>
|
||||
<div class="flex items-center gap-3">
|
||||
<% if align == :left %>
|
||||
<%= helpers.icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.span class: class_names("font-medium", align == :left ? "text-sm text-primary" : "text-xs uppercase text-secondary") do %>
|
||||
<%= title %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if align == :right %>
|
||||
<%= helpers.icon "chevron-down", class: "group-open:transform group-open:rotate-180" %>
|
||||
<% elsif summary_content? %>
|
||||
<%= summary_content %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-2">
|
||||
<%= content %>
|
||||
</div>
|
||||
</details>
|
||||
@@ -1,18 +0,0 @@
|
||||
<%= tag.div data: {
|
||||
controller: "tabs",
|
||||
testid: testid,
|
||||
tabs_session_key_value: session_key,
|
||||
tabs_url_param_key_value: url_param_key,
|
||||
tabs_nav_btn_active_class: active_btn_classes,
|
||||
tabs_nav_btn_inactive_class: inactive_btn_classes
|
||||
} do %>
|
||||
<% if unstyled? %>
|
||||
<%= content %>
|
||||
<% else %>
|
||||
<%= nav %>
|
||||
|
||||
<% panels.each do |panel| %>
|
||||
<%= panel %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -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.visible.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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class AccountsController < ApplicationController
|
||||
before_action :set_account, only: %i[sync chart sparkline]
|
||||
before_action :set_account, only: %i[sync sparkline toggle_active show destroy]
|
||||
include Periodable
|
||||
|
||||
def index
|
||||
@@ -9,6 +9,22 @@ class AccountsController < ApplicationController
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def sync_all
|
||||
family.sync_later
|
||||
redirect_to accounts_path, notice: "Syncing accounts..."
|
||||
end
|
||||
|
||||
def show
|
||||
@chart_view = params[:chart_view] || "balance"
|
||||
@tab = params[:tab]
|
||||
@q = params.fetch(:q, {}).permit(:search)
|
||||
entries = @account.entries.search(@q).reverse_chronological
|
||||
|
||||
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10")
|
||||
|
||||
@activity_feed_data = Account::ActivityFeedData.new(@account, @entries)
|
||||
end
|
||||
|
||||
def sync
|
||||
unless @account.syncing?
|
||||
@account.sync_later
|
||||
@@ -17,13 +33,33 @@ class AccountsController < ApplicationController
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
|
||||
def chart
|
||||
@chart_view = params[:chart_view] || "balance"
|
||||
render layout: "application"
|
||||
def sparkline
|
||||
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
|
||||
|
||||
def sparkline
|
||||
render layout: false
|
||||
def toggle_active
|
||||
if @account.active?
|
||||
@account.disable!
|
||||
elsif @account.disabled?
|
||||
@account.enable!
|
||||
end
|
||||
redirect_to accounts_path
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @account.linked?
|
||||
redirect_to account_path(@account), alert: "Cannot delete a linked account"
|
||||
else
|
||||
@account.destroy_later
|
||||
redirect_to accounts_path, notice: "Account scheduled for deletion"
|
||||
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.visible.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.limit(@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.visible
|
||||
|
||||
# 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
|
||||
@@ -2,9 +2,9 @@ module AccountableResource
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include ScrollFocusable, Periodable
|
||||
include Periodable
|
||||
|
||||
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
|
||||
before_action :set_account, only: [ :show, :edit, :update ]
|
||||
before_action :set_link_options, only: :new
|
||||
end
|
||||
|
||||
@@ -27,9 +27,7 @@ module AccountableResource
|
||||
@q = params.fetch(:q, {}).permit(:search)
|
||||
entries = @account.entries.search(@q).reverse_chronological
|
||||
|
||||
set_focused_record(entries, params[:focused_record_id])
|
||||
|
||||
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10", params: ->(params) { params.except(:focused_record_id) })
|
||||
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10")
|
||||
end
|
||||
|
||||
def edit
|
||||
@@ -43,19 +41,27 @@ module AccountableResource
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params.except(:return_to))
|
||||
@account.lock_saved_attributes!
|
||||
|
||||
redirect_back_or_to @account, notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @account.linked?
|
||||
redirect_to account_path(@account), alert: "Cannot delete a linked account"
|
||||
else
|
||||
@account.destroy_later
|
||||
redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize)
|
||||
# Handle balance update if provided
|
||||
if account_params[:balance].present?
|
||||
result = @account.set_current_balance(account_params[:balance].to_d)
|
||||
unless result.success?
|
||||
@error_message = result.error_message
|
||||
render :edit, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
@account.sync_later
|
||||
end
|
||||
|
||||
# Update remaining account attributes
|
||||
update_params = account_params.except(:return_to, :balance, :currency)
|
||||
unless @account.update(update_params)
|
||||
@error_message = @account.errors.full_messages.join(", ")
|
||||
render :edit, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@account.lock_saved_attributes!
|
||||
redirect_back_or_to account_path(@account), notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -74,7 +80,7 @@ module AccountableResource
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(
|
||||
:name, :is_active, :balance, :subtype, :currency, :accountable_type, :return_to,
|
||||
:name, :balance, :subtype, :currency, :accountable_type, :return_to,
|
||||
accountable_attributes: self.class.permitted_accountable_attributes
|
||||
)
|
||||
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,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
module ScrollFocusable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def set_focused_record(record_scope, record_id, default_per_page: 10)
|
||||
return unless record_id.present?
|
||||
|
||||
@focused_record = record_scope.find_by(id: record_id)
|
||||
|
||||
record_index = record_scope.pluck(:id).index(record_id)
|
||||
|
||||
return unless record_index
|
||||
|
||||
page_of_focused_record = (record_index / (params[:per_page]&.to_i || default_per_page)) + 1
|
||||
|
||||
if params[:page]&.to_i != page_of_focused_record
|
||||
(
|
||||
redirect_to(url_for(page: page_of_focused_record, focused_record_id: record_id))
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
47
app/controllers/family_exports_controller.rb
Normal file
47
app/controllers/family_exports_controller.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
class FamilyExportsController < ApplicationController
|
||||
include StreamExtensions
|
||||
|
||||
before_action :require_admin
|
||||
before_action :set_export, only: [ :download ]
|
||||
|
||||
def new
|
||||
# Modal view for initiating export
|
||||
end
|
||||
|
||||
def create
|
||||
@export = Current.family.family_exports.create!
|
||||
FamilyDataExportJob.perform_later(@export)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly." }
|
||||
format.turbo_stream {
|
||||
stream_redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly."
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def index
|
||||
@exports = Current.family.family_exports.ordered.limit(10)
|
||||
render layout: false # For turbo frame
|
||||
end
|
||||
|
||||
def download
|
||||
if @export.downloadable?
|
||||
redirect_to @export.export_file, allow_other_host: true
|
||||
else
|
||||
redirect_to settings_profile_path, alert: "Export not ready for download"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_export
|
||||
@export = Current.family.family_exports.find(params[:id])
|
||||
end
|
||||
|
||||
def require_admin
|
||||
unless Current.user.admin?
|
||||
redirect_to root_path, alert: "Access denied"
|
||||
end
|
||||
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,10 +1,11 @@
|
||||
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
|
||||
@accounts = Current.family.accounts.visible.with_attached_logo
|
||||
|
||||
period_param = params[:cashflow_period]
|
||||
@cashflow_period = if period_param.present?
|
||||
@@ -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
|
||||
|
||||
@@ -1,21 +1,99 @@
|
||||
class PropertiesController < ApplicationController
|
||||
include AccountableResource
|
||||
include AccountableResource, StreamExtensions
|
||||
|
||||
permitted_accountable_attributes(
|
||||
:id, :year_built, :area_unit, :area_value,
|
||||
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
|
||||
)
|
||||
before_action :set_property, only: [ :balances, :address, :update_balances, :update_address ]
|
||||
|
||||
def new
|
||||
@account = Current.family.accounts.build(
|
||||
currency: Current.family.currency,
|
||||
accountable: Property.new(
|
||||
address: Address.new
|
||||
)
|
||||
@account = Current.family.accounts.build(accountable: Property.new)
|
||||
end
|
||||
|
||||
def create
|
||||
@account = Current.family.accounts.create!(
|
||||
property_params.merge(currency: Current.family.currency, balance: 0, status: "draft")
|
||||
)
|
||||
|
||||
redirect_to balances_property_path(@account)
|
||||
end
|
||||
|
||||
def update
|
||||
if @account.update(property_params)
|
||||
@success_message = "Property details updated successfully."
|
||||
|
||||
if @account.active?
|
||||
render :edit
|
||||
else
|
||||
redirect_to balances_property_path(@account)
|
||||
end
|
||||
else
|
||||
@error_message = "Unable to update property details."
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@account.accountable.address ||= Address.new
|
||||
end
|
||||
|
||||
def balances
|
||||
end
|
||||
|
||||
def update_balances
|
||||
result = @account.set_current_balance(balance_params[:balance].to_d)
|
||||
|
||||
if result.success?
|
||||
@success_message = "Balance updated successfully."
|
||||
|
||||
if @account.active?
|
||||
render :balances
|
||||
else
|
||||
redirect_to address_property_path(@account)
|
||||
end
|
||||
else
|
||||
@error_message = result.error_message
|
||||
render :balances, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def address
|
||||
@property = @account.property
|
||||
@property.address ||= Address.new
|
||||
end
|
||||
|
||||
def update_address
|
||||
if @account.property.update(address_params)
|
||||
if @account.draft?
|
||||
@account.activate!
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account) }
|
||||
format.turbo_stream { stream_redirect_to account_path(@account) }
|
||||
end
|
||||
else
|
||||
@success_message = "Address updated successfully."
|
||||
render :address
|
||||
end
|
||||
else
|
||||
@error_message = "Unable to update address. Please check the required fields."
|
||||
render :address, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def balance_params
|
||||
params.require(:account).permit(:balance, :currency)
|
||||
end
|
||||
|
||||
def address_params
|
||||
params.require(:property)
|
||||
.permit(address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ])
|
||||
end
|
||||
|
||||
def property_params
|
||||
params.require(:account)
|
||||
.permit(:name, :subtype, :accountable_type, accountable_attributes: [ :id, :year_built, :area_unit, :area_value ])
|
||||
end
|
||||
|
||||
def set_property
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
@property = @account.property
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class TransactionsController < ApplicationController
|
||||
include ScrollFocusable, EntryableResource
|
||||
include EntryableResource
|
||||
|
||||
before_action :store_params!, only: :index
|
||||
|
||||
@@ -11,39 +11,17 @@ 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) }
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 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)
|
||||
end
|
||||
@pagy, @transactions = pagy(base_scope, limit: per_page)
|
||||
end
|
||||
|
||||
def clear_filter
|
||||
@@ -66,6 +44,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 +109,10 @@ class TransactionsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
def per_page
|
||||
params[:per_page].to_i.positive? ? params[:per_page].to_i : 20
|
||||
end
|
||||
|
||||
def needs_rule_notification?(transaction)
|
||||
return false if Current.user.rule_prompts_disabled
|
||||
|
||||
@@ -142,7 +128,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 +145,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,36 +154,6 @@ 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
|
||||
end
|
||||
|
||||
cleaned_params
|
||||
end
|
||||
@@ -205,9 +162,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 +185,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
|
||||
|
||||
@@ -2,13 +2,18 @@ class TransferMatchesController < ApplicationController
|
||||
before_action :set_entry
|
||||
|
||||
def new
|
||||
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
|
||||
@accounts = Current.family.accounts.visible.alphabetically.where.not(id: @entry.account_id)
|
||||
@transfer_match_candidates = @entry.transaction.transfer_match_candidates
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -1,30 +1,70 @@
|
||||
class ValuationsController < ApplicationController
|
||||
include EntryableResource
|
||||
include EntryableResource, StreamExtensions
|
||||
|
||||
def confirm_create
|
||||
@account = Current.family.accounts.find(params.dig(:entry, :account_id))
|
||||
@entry = @account.entries.build(entry_params.merge(currency: @account.currency))
|
||||
|
||||
@reconciliation_dry_run = @entry.account.create_reconciliation(
|
||||
balance: entry_params[:amount],
|
||||
date: entry_params[:date],
|
||||
dry_run: true
|
||||
)
|
||||
|
||||
render :confirm_create
|
||||
end
|
||||
|
||||
def confirm_update
|
||||
@entry = Current.family.entries.find(params[:id])
|
||||
@account = @entry.account
|
||||
@entry.assign_attributes(entry_params.merge(currency: @account.currency))
|
||||
|
||||
@reconciliation_dry_run = @entry.account.update_reconciliation(
|
||||
@entry,
|
||||
balance: entry_params[:amount],
|
||||
date: entry_params[:date],
|
||||
dry_run: true
|
||||
)
|
||||
|
||||
render :confirm_update
|
||||
end
|
||||
|
||||
def create
|
||||
account = Current.family.accounts.find(params.dig(:entry, :account_id))
|
||||
@entry = account.entries.new(entry_params.merge(entryable: Valuation.new))
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
|
||||
flash[:notice] = "Balance created"
|
||||
result = account.create_reconciliation(
|
||||
balance: entry_params[:amount],
|
||||
date: entry_params[:date],
|
||||
)
|
||||
|
||||
if result.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), notice: "Account updated" }
|
||||
format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: "Account updated") }
|
||||
end
|
||||
else
|
||||
@error_message = result.error_message
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @entry.update(entry_params)
|
||||
@entry.sync_account_later
|
||||
# Notes updating is independent of reconciliation, just a simple CRUD operation
|
||||
@entry.update!(notes: entry_params[:notes]) if entry_params[:notes].present?
|
||||
|
||||
if entry_params[:date].present? && entry_params[:amount].present?
|
||||
result = @entry.account.update_reconciliation(
|
||||
@entry,
|
||||
balance: entry_params[:amount],
|
||||
date: entry_params[:date],
|
||||
)
|
||||
end
|
||||
|
||||
if result.nil? || result.success?
|
||||
@entry.reload
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: "Balance updated" }
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: "Entry updated" }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
@@ -37,13 +77,13 @@ class ValuationsController < ApplicationController
|
||||
end
|
||||
end
|
||||
else
|
||||
@error_message = result.error_message
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def entry_params
|
||||
params.require(:entry)
|
||||
.permit(:name, :date, :amount, :currency, :notes)
|
||||
params.require(:entry).permit(:date, :amount, :notes)
|
||||
end
|
||||
end
|
||||
|
||||
59
app/data_migrations/balance_component_migrator.rb
Normal file
59
app/data_migrations/balance_component_migrator.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
class BalanceComponentMigrator
|
||||
def self.run
|
||||
ActiveRecord::Base.transaction do
|
||||
# Step 1: Update flows factor
|
||||
ActiveRecord::Base.connection.execute <<~SQL
|
||||
UPDATE balances SET
|
||||
flows_factor = CASE WHEN a.classification = 'asset' THEN 1 ELSE -1 END
|
||||
FROM accounts a
|
||||
WHERE a.id = balances.account_id
|
||||
SQL
|
||||
|
||||
# Step 2: Set start values using LOCF (Last Observation Carried Forward)
|
||||
ActiveRecord::Base.connection.execute <<~SQL
|
||||
UPDATE balances b1
|
||||
SET
|
||||
start_cash_balance = COALESCE(prev.cash_balance, 0),
|
||||
start_non_cash_balance = COALESCE(prev.balance - prev.cash_balance, 0)
|
||||
FROM balances b1_inner
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
b2.cash_balance,
|
||||
b2.balance
|
||||
FROM balances b2
|
||||
WHERE b2.account_id = b1_inner.account_id
|
||||
AND b2.currency = b1_inner.currency
|
||||
AND b2.date < b1_inner.date
|
||||
ORDER BY b2.date DESC
|
||||
LIMIT 1
|
||||
) prev ON true
|
||||
WHERE b1.id = b1_inner.id
|
||||
SQL
|
||||
|
||||
# Step 3: Calculate net inflows
|
||||
# A slight workaround to the fact that we can't easily derive inflows/outflows from our current data model, and
|
||||
# the tradeoff not worth it since each new sync will fix it. So instead, we sum up *net* flows, and throw the signed
|
||||
# amount in the "inflows" column, and zero-out the "outflows" column so our math works correctly with incomplete data.
|
||||
ActiveRecord::Base.connection.execute <<~SQL
|
||||
UPDATE balances SET
|
||||
cash_inflows = (cash_balance - start_cash_balance) * flows_factor,
|
||||
cash_outflows = 0,
|
||||
non_cash_inflows = ((balance - cash_balance) - start_non_cash_balance) * flows_factor,
|
||||
non_cash_outflows = 0,
|
||||
net_market_flows = 0
|
||||
SQL
|
||||
|
||||
# Verify data integrity
|
||||
# All end_balance values should match the original balance
|
||||
invalid_count = ActiveRecord::Base.connection.select_value(<<~SQL)
|
||||
SELECT COUNT(*)
|
||||
FROM balances b
|
||||
WHERE ABS(b.balance - b.end_balance) > 0.0001
|
||||
SQL
|
||||
|
||||
if invalid_count > 0
|
||||
raise "Data migration failed validation: #{invalid_count} balances have incorrect end_balance values"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -21,7 +21,7 @@ module ApplicationHelper
|
||||
if custom
|
||||
inline_svg_tag("#{key}.svg", class: icon_classes, **opts)
|
||||
elsif as_button
|
||||
render ButtonComponent.new(variant: "icon", class: extra_classes, icon: key, size: size, type: "button", **opts)
|
||||
render DS::Button.new(variant: "icon", class: extra_classes, icon: key, size: size, type: "button", **opts)
|
||||
else
|
||||
lucide_icon(key, class: icon_classes, **opts)
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
module SettingsHelper
|
||||
SETTINGS_ORDER = [
|
||||
{ name: I18n.t("settings.settings_nav.profile_label"), path: :settings_profile_path },
|
||||
{ name: I18n.t("settings.settings_nav.preferences_label"), path: :settings_preferences_path },
|
||||
{ name: I18n.t("settings.settings_nav.security_label"), path: :settings_security_path },
|
||||
{ name: I18n.t("settings.settings_nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
|
||||
{ name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path, condition: :not_self_hosted? },
|
||||
{ name: I18n.t("settings.settings_nav.accounts_label"), path: :accounts_path },
|
||||
{ name: I18n.t("settings.settings_nav.imports_label"), path: :imports_path },
|
||||
{ name: I18n.t("settings.settings_nav.tags_label"), path: :tags_path },
|
||||
{ name: I18n.t("settings.settings_nav.categories_label"), path: :categories_path },
|
||||
{ name: "Account", path: :settings_profile_path },
|
||||
{ name: "Preferences", path: :settings_preferences_path },
|
||||
{ name: "Security", path: :settings_security_path },
|
||||
{ name: "Self hosting", path: :settings_hosting_path, condition: :self_hosted? },
|
||||
{ name: "API Key", path: :settings_api_key_path },
|
||||
{ name: "Billing", path: :settings_billing_path, condition: :not_self_hosted? },
|
||||
{ name: "Accounts", path: :accounts_path },
|
||||
{ name: "Imports", path: :imports_path },
|
||||
{ name: "Tags", path: :tags_path },
|
||||
{ name: "Categories", path: :categories_path },
|
||||
{ name: "Rules", path: :rules_path },
|
||||
{ name: I18n.t("settings.settings_nav.merchants_label"), path: :family_merchants_path },
|
||||
{ name: I18n.t("settings.settings_nav.whats_new_label"), path: :changelog_path },
|
||||
{ name: I18n.t("settings.settings_nav.feedback_label"), path: :feedback_path }
|
||||
{ name: "Merchants", path: :family_merchants_path },
|
||||
{ name: "What's new", path: :changelog_path },
|
||||
{ name: "Feedback", path: :feedback_path }
|
||||
]
|
||||
|
||||
def adjacent_setting(current_path, offset)
|
||||
|
||||
@@ -1,42 +1,38 @@
|
||||
class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
# Fields that visually inherit from "text field"
|
||||
class_attribute :text_field_helpers, default: field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]
|
||||
|
||||
# Wraps "text" inputs with custom structure + base styles
|
||||
text_field_helpers.each do |selector|
|
||||
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
||||
def #{selector}(method, options = {})
|
||||
merged_options = { class: "form-field__input" }.merge(options)
|
||||
label = build_label(method, options)
|
||||
field = super(method, merged_options)
|
||||
form_options = options.slice(:label, :label_tooltip, :inline, :container_class, :required)
|
||||
html_options = options.except(:label, :label_tooltip, :inline, :container_class)
|
||||
|
||||
build_styled_field(label, field, merged_options)
|
||||
build_field(method, form_options, html_options) do |merged_options|
|
||||
super(method, merged_options)
|
||||
end
|
||||
end
|
||||
RUBY_EVAL
|
||||
end
|
||||
|
||||
def radio_button(method, tag_value, options = {})
|
||||
merged_options = { class: "form-field__radio" }.merge(options)
|
||||
|
||||
super(method, tag_value, merged_options)
|
||||
end
|
||||
|
||||
def select(method, choices, options = {}, html_options = {})
|
||||
merged_html_options = { class: "form-field__input" }.merge(html_options)
|
||||
field_options = normalize_options(options, html_options)
|
||||
|
||||
label = build_label(method, options.merge(required: merged_html_options[:required]))
|
||||
field = super(method, choices, options, merged_html_options)
|
||||
|
||||
build_styled_field(label, field, options, remove_padding_right: true)
|
||||
build_field(method, field_options, html_options) do |merged_html_options|
|
||||
super(method, choices, options, merged_html_options)
|
||||
end
|
||||
end
|
||||
|
||||
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
|
||||
merged_html_options = { class: "form-field__input" }.merge(html_options)
|
||||
field_options = normalize_options(options, html_options)
|
||||
|
||||
label = build_label(method, options.merge(required: merged_html_options[:required]))
|
||||
field = super(method, collection, value_method, text_method, options, merged_html_options)
|
||||
|
||||
build_styled_field(label, field, options, remove_padding_right: true)
|
||||
build_field(method, field_options, html_options) do |merged_html_options|
|
||||
super(method, collection, value_method, text_method, options, merged_html_options)
|
||||
end
|
||||
end
|
||||
|
||||
def money_field(amount_method, options = {})
|
||||
@@ -48,22 +44,15 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
}
|
||||
end
|
||||
|
||||
# A custom styled "toggle" switch input. Underlying input is a `check_box` (uses same API)
|
||||
def toggle(method, options = {}, checked_value = "1", unchecked_value = "0")
|
||||
if object
|
||||
id = "#{object.id}_#{object_name}_#{method}"
|
||||
name = "#{object_name}[#{method}]"
|
||||
checked = object.send(method)
|
||||
else
|
||||
id = "#{method}_toggle_id"
|
||||
name = method
|
||||
checked = options[:checked]
|
||||
end
|
||||
field_id = field_id(method)
|
||||
field_name = field_name(method)
|
||||
checked = object ? object.send(method) : options[:checked]
|
||||
|
||||
@template.render(
|
||||
ToggleComponent.new(
|
||||
id: id,
|
||||
name: name,
|
||||
DS::Toggle.new(
|
||||
id: field_id,
|
||||
name: field_name,
|
||||
checked: checked,
|
||||
disabled: options[:disabled],
|
||||
checked_value: checked_value,
|
||||
@@ -74,12 +63,11 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
end
|
||||
|
||||
def submit(value = nil, options = {})
|
||||
# Rails superclass logic to extract the submit text
|
||||
value, options = nil, value if value.is_a?(Hash)
|
||||
value ||= submit_default_value
|
||||
|
||||
@template.render(
|
||||
ButtonComponent.new(
|
||||
DS::Button.new(
|
||||
text: value,
|
||||
data: (options[:data] || {}).merge({ turbo_submits_with: "Submitting..." }),
|
||||
full_width: true
|
||||
@@ -88,16 +76,39 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
end
|
||||
|
||||
private
|
||||
def build_styled_field(label, field, options, remove_padding_right: false)
|
||||
if options[:inline]
|
||||
label + field
|
||||
else
|
||||
@template.tag.div class: [ "form-field", options[:container_class], ("pr-0" if remove_padding_right) ] do
|
||||
label + field
|
||||
def build_field(method, options = {}, html_options = {}, &block)
|
||||
if options[:inline] || options[:label] == false
|
||||
return yield({ class: "form-field__input" }.merge(html_options))
|
||||
end
|
||||
|
||||
label_element = build_label(method, options)
|
||||
field_element = yield({ class: "form-field__input" }.merge(html_options))
|
||||
|
||||
container_classes = [ "form-field", options[:container_class] ].compact
|
||||
|
||||
@template.tag.div class: container_classes do
|
||||
if options[:label_tooltip]
|
||||
@template.tag.div(class: "form-field__header") do
|
||||
label_element +
|
||||
@template.tag.div(class: "form-field__actions") do
|
||||
build_tooltip(options[:label_tooltip])
|
||||
end
|
||||
end +
|
||||
@template.tag.div(class: "form-field__body") do
|
||||
field_element
|
||||
end
|
||||
else
|
||||
@template.tag.div(class: "form-field__body") do
|
||||
label_element + field_element
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_options(options, html_options)
|
||||
options.merge(required: options[:required] || html_options[:required])
|
||||
end
|
||||
|
||||
def build_label(method, options)
|
||||
return "".html_safe unless options[:label]
|
||||
|
||||
@@ -113,4 +124,15 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
return label(method, class: "form-field__label") if label_text == true
|
||||
label(method, label_text, class: "form-field__label")
|
||||
end
|
||||
|
||||
def build_tooltip(tooltip_text)
|
||||
return nil unless tooltip_text
|
||||
|
||||
@template.tag.div(data: { controller: "tooltip" }) do
|
||||
@template.safe_join([
|
||||
@template.icon("help-circle", size: "sm", color: "default", class: "cursor-help"),
|
||||
@template.tag.div(tooltip_text, role: "tooltip", data: { tooltip_target: "tooltip" }, class: "tooltip bg-gray-700 text-sm p-2 rounded w-64 text-white")
|
||||
])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,16 +10,14 @@ export default class extends Controller {
|
||||
|
||||
connect() {
|
||||
this.autoTargets.forEach((element) => {
|
||||
const event =
|
||||
element.dataset.autosubmitTriggerEvent || this.triggerEventValue;
|
||||
const event = this.#getTriggerEvent(element);
|
||||
element.addEventListener(event, this.handleInput);
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.autoTargets.forEach((element) => {
|
||||
const event =
|
||||
element.dataset.autosubmitTriggerEvent || this.triggerEventValue;
|
||||
const event = this.#getTriggerEvent(element);
|
||||
element.removeEventListener(event, this.handleInput);
|
||||
});
|
||||
}
|
||||
@@ -33,6 +31,50 @@ export default class extends Controller {
|
||||
}, this.#debounceTimeout(target));
|
||||
};
|
||||
|
||||
#getTriggerEvent(element) {
|
||||
// Check if element has explicit trigger event set
|
||||
if (element.dataset.autosubmitTriggerEvent) {
|
||||
return element.dataset.autosubmitTriggerEvent;
|
||||
}
|
||||
|
||||
// Check if form has explicit trigger event set
|
||||
if (this.triggerEventValue !== "input") {
|
||||
return this.triggerEventValue;
|
||||
}
|
||||
|
||||
// Otherwise, choose trigger event based on element type
|
||||
const type = element.type || element.tagName;
|
||||
|
||||
switch (type.toLowerCase()) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "password":
|
||||
case "search":
|
||||
case "tel":
|
||||
case "url":
|
||||
case "textarea":
|
||||
return "blur";
|
||||
case "number":
|
||||
case "date":
|
||||
case "datetime-local":
|
||||
case "month":
|
||||
case "time":
|
||||
case "week":
|
||||
case "color":
|
||||
return "change";
|
||||
case "checkbox":
|
||||
case "radio":
|
||||
case "select":
|
||||
case "select-one":
|
||||
case "select-multiple":
|
||||
return "change";
|
||||
case "range":
|
||||
return "input";
|
||||
default:
|
||||
return "blur";
|
||||
}
|
||||
}
|
||||
|
||||
#debounceTimeout(element) {
|
||||
if (element.dataset.autosubmitDebounceTimeout) {
|
||||
return Number.parseInt(element.dataset.autosubmitDebounceTimeout);
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="focus-record"
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
id: String,
|
||||
};
|
||||
|
||||
connect() {
|
||||
const element = document.getElementById(this.idValue);
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
|
||||
// Remove the focused_record_id parameter from URL
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.delete("focused_record_id");
|
||||
window.history.replaceState({}, "", url);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -508,15 +508,57 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
get _d3YScale() {
|
||||
const reductionPercent = this.useLabelsValue ? 0.3 : 0.05;
|
||||
const dataMin = d3.min(this._normalDataPoints, this._getDatumValue);
|
||||
const dataMax = d3.max(this._normalDataPoints, this._getDatumValue);
|
||||
const padding = (dataMax - dataMin) * reductionPercent;
|
||||
|
||||
// Handle edge case where all values are the same
|
||||
if (dataMin === dataMax) {
|
||||
const padding = dataMax === 0 ? 100 : Math.abs(dataMax) * 0.5;
|
||||
return d3
|
||||
.scaleLinear()
|
||||
.rangeRound([this._d3ContainerHeight, 0])
|
||||
.domain([dataMin - padding, dataMax + padding]);
|
||||
}
|
||||
|
||||
const dataRange = dataMax - dataMin;
|
||||
const avgValue = (dataMax + dataMin) / 2;
|
||||
|
||||
// Calculate relative change as a percentage
|
||||
const relativeChange = avgValue !== 0 ? dataRange / Math.abs(avgValue) : 1;
|
||||
|
||||
// Dynamic baseline calculation
|
||||
let yMin;
|
||||
let yMax;
|
||||
|
||||
// For small relative changes (< 10%), use a tighter scale
|
||||
if (relativeChange < 0.1 && dataMin > 0) {
|
||||
// Start axis at a percentage below the minimum, not at 0
|
||||
const baselinePadding = dataRange * 2; // Show 2x the data range below min
|
||||
yMin = Math.max(0, dataMin - baselinePadding);
|
||||
yMax = dataMax + dataRange * 0.5; // Add 50% padding above
|
||||
} else {
|
||||
// For larger changes or when data crosses zero, use more context
|
||||
// Always include 0 when data is negative or close to 0
|
||||
if (dataMin < 0 || (dataMin >= 0 && dataMin < avgValue * 0.1)) {
|
||||
yMin = Math.min(0, dataMin * 1.1);
|
||||
} else {
|
||||
// Otherwise use dynamic baseline
|
||||
yMin = dataMin - dataRange * 0.3;
|
||||
}
|
||||
yMax = dataMax + dataRange * 0.1;
|
||||
}
|
||||
|
||||
// Adjust padding for labels if needed
|
||||
if (this.useLabelsValue) {
|
||||
const extraPadding = (yMax - yMin) * 0.1;
|
||||
yMin -= extraPadding;
|
||||
yMax += extraPadding;
|
||||
}
|
||||
|
||||
return d3
|
||||
.scaleLinear()
|
||||
.rangeRound([this._d3ContainerHeight, 0])
|
||||
.domain([dataMin - padding, dataMax + padding]);
|
||||
.domain([yMin, yMax]);
|
||||
}
|
||||
|
||||
_setupResizeObserver() {
|
||||
|
||||
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>
|
||||
`
|
||||
}
|
||||
}
|
||||
22
app/jobs/family_data_export_job.rb
Normal file
22
app/jobs/family_data_export_job.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class FamilyDataExportJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(family_export)
|
||||
family_export.update!(status: :processing)
|
||||
|
||||
exporter = Family::DataExporter.new(family_export.family)
|
||||
zip_file = exporter.generate_export
|
||||
|
||||
family_export.export_file.attach(
|
||||
io: zip_file,
|
||||
filename: family_export.filename,
|
||||
content_type: "application/zip"
|
||||
)
|
||||
|
||||
family_export.update!(status: :completed)
|
||||
rescue => e
|
||||
Rails.logger.error "Family export failed: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
family_export.update!(status: :failed)
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Account < ApplicationRecord
|
||||
include Syncable, Monetizable, Chartable, Linkable, Enrichable
|
||||
include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable
|
||||
|
||||
validates :name, :balance, :currency, presence: true
|
||||
|
||||
@@ -18,7 +18,7 @@ class Account < ApplicationRecord
|
||||
|
||||
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
|
||||
|
||||
scope :active, -> { where(is_active: true) }
|
||||
scope :visible, -> { where(status: [ "draft", "active" ]) }
|
||||
scope :assets, -> { where(classification: "asset") }
|
||||
scope :liabilities, -> { where(classification: "liability") }
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
@@ -30,30 +30,42 @@ class Account < ApplicationRecord
|
||||
|
||||
accepts_nested_attributes_for :accountable, update_only: true
|
||||
|
||||
# Account state machine
|
||||
aasm column: :status, timestamps: true do
|
||||
state :active, initial: true
|
||||
state :draft
|
||||
state :disabled
|
||||
state :pending_deletion
|
||||
|
||||
event :activate do
|
||||
transitions from: [ :draft, :disabled ], to: :active
|
||||
end
|
||||
|
||||
event :disable do
|
||||
transitions from: [ :draft, :active ], to: :disabled
|
||||
end
|
||||
|
||||
event :enable do
|
||||
transitions from: :disabled, to: :active
|
||||
end
|
||||
|
||||
event :mark_for_deletion do
|
||||
transitions from: [ :draft, :active, :disabled ], to: :pending_deletion
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def create_and_sync(attributes)
|
||||
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
|
||||
account = new(attributes.merge(cash_balance: attributes[:balance]))
|
||||
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d || 0
|
||||
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d
|
||||
|
||||
transaction do
|
||||
# Create 2 valuations for new accounts to establish a value history for users to see
|
||||
account.entries.build(
|
||||
name: "Current Balance",
|
||||
date: Date.current,
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new
|
||||
)
|
||||
account.entries.build(
|
||||
name: "Initial Balance",
|
||||
date: 1.day.ago.to_date,
|
||||
amount: initial_balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new
|
||||
)
|
||||
|
||||
account.save!
|
||||
|
||||
manager = Account::OpeningBalanceManager.new(account)
|
||||
result = manager.set_opening_balance(balance: initial_balance || account.balance)
|
||||
raise result.error if result.error
|
||||
end
|
||||
|
||||
account.sync_later
|
||||
@@ -61,18 +73,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?
|
||||
@@ -89,57 +89,29 @@ class Account < ApplicationRecord
|
||||
end
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true, is_active: false)
|
||||
mark_for_deletion!
|
||||
DestroyJob.perform_later(self)
|
||||
end
|
||||
|
||||
# Override destroy to handle error recovery for accounts
|
||||
def destroy
|
||||
super
|
||||
rescue => e
|
||||
# If destruction fails, transition back to disabled state
|
||||
# This provides a cleaner recovery path than the generic scheduled_for_deletion flag
|
||||
disable! if may_disable?
|
||||
raise e
|
||||
end
|
||||
|
||||
def current_holdings
|
||||
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
|
||||
end
|
||||
|
||||
def update_with_sync!(attributes)
|
||||
should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance
|
||||
|
||||
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)
|
||||
should_update_initial_balance = initial_balance && initial_balance.to_d != accountable.initial_balance
|
||||
|
||||
transaction do
|
||||
update!(attributes)
|
||||
update_balance!(attributes[:balance]) if should_update_balance
|
||||
update_inital_balance!(attributes[:accountable_attributes][:initial_balance]) if should_update_initial_balance
|
||||
end
|
||||
|
||||
sync_later
|
||||
end
|
||||
|
||||
def update_balance!(balance)
|
||||
valuation = entries.valuations.find_by(date: Date.current)
|
||||
|
||||
if valuation
|
||||
valuation.update! amount: balance
|
||||
else
|
||||
entries.create! \
|
||||
date: Date.current,
|
||||
name: "Balance update",
|
||||
amount: balance,
|
||||
currency: currency,
|
||||
entryable: Valuation.new
|
||||
end
|
||||
end
|
||||
|
||||
def update_inital_balance!(initial_balance)
|
||||
valuation = first_valuation
|
||||
|
||||
if valuation
|
||||
valuation.update! amount: initial_balance
|
||||
else
|
||||
entries.create! \
|
||||
date: Date.current,
|
||||
name: "Initial Balance",
|
||||
amount: initial_balance,
|
||||
currency: currency,
|
||||
entryable: Valuation.new
|
||||
end
|
||||
holdings.where(currency: currency)
|
||||
.where.not(qty: 0)
|
||||
.where(
|
||||
id: holdings.select("DISTINCT ON (security_id) id")
|
||||
.where(currency: currency)
|
||||
.order(:security_id, date: :desc)
|
||||
)
|
||||
.order(amount: :desc)
|
||||
end
|
||||
|
||||
def start_date
|
||||
@@ -169,4 +141,23 @@ class Account < ApplicationRecord
|
||||
def long_subtype_label
|
||||
accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name
|
||||
end
|
||||
|
||||
# The balance type determines which "component" of balance is being tracked.
|
||||
# This is primarily used for balance related calculations and updates.
|
||||
#
|
||||
# "Cash" = "Liquid"
|
||||
# "Non-cash" = "Illiquid"
|
||||
# "Investment" = A mix of both, including brokerage cash (liquid) and holdings (illiquid)
|
||||
def balance_type
|
||||
case accountable_type
|
||||
when "Depository", "CreditCard"
|
||||
:cash
|
||||
when "Property", "Vehicle", "OtherAsset", "Loan", "OtherLiability"
|
||||
:non_cash
|
||||
when "Investment", "Crypto"
|
||||
:investment
|
||||
else
|
||||
raise "Unknown account type: #{accountable_type}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
85
app/models/account/activity_feed_data.rb
Normal file
85
app/models/account/activity_feed_data.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
# Data used to build the paginated feed of account "activity" (events like transfers, deposits, withdrawals, etc.)
|
||||
# This data object is useful for avoiding N+1 queries and having an easy way to pass around the required data to the
|
||||
# activity feed component in controllers and background jobs that refresh it.
|
||||
class Account::ActivityFeedData
|
||||
ActivityDateData = Data.define(:date, :entries, :balance, :transfers)
|
||||
|
||||
attr_reader :account, :entries
|
||||
|
||||
def initialize(account, entries)
|
||||
@account = account
|
||||
@entries = entries.to_a
|
||||
end
|
||||
|
||||
def entries_by_date
|
||||
@entries_by_date_objects ||= begin
|
||||
grouped_entries.map do |date, date_entries|
|
||||
ActivityDateData.new(
|
||||
date: date,
|
||||
entries: date_entries,
|
||||
balance: balance_for_date(date),
|
||||
transfers: transfers_for_date(date)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def balance_for_date(date)
|
||||
balances_by_date[date]
|
||||
end
|
||||
|
||||
def transfers_for_date(date)
|
||||
transfers_by_date[date] || []
|
||||
end
|
||||
|
||||
def grouped_entries
|
||||
@grouped_entries ||= entries.group_by(&:date)
|
||||
end
|
||||
|
||||
def balances_by_date
|
||||
@balances_by_date ||= begin
|
||||
return {} if entries.empty?
|
||||
|
||||
dates = grouped_entries.keys
|
||||
account.balances
|
||||
.where(date: dates, currency: account.currency)
|
||||
.index_by(&:date)
|
||||
end
|
||||
end
|
||||
|
||||
def transfers_by_date
|
||||
@transfers_by_date ||= begin
|
||||
return {} if transaction_ids.empty?
|
||||
|
||||
transfers = Transfer
|
||||
.where(inflow_transaction_id: transaction_ids)
|
||||
.or(Transfer.where(outflow_transaction_id: transaction_ids))
|
||||
.to_a
|
||||
|
||||
# Group transfers by the date of their transaction entries
|
||||
result = Hash.new { |h, k| h[k] = [] }
|
||||
|
||||
entries.each do |entry|
|
||||
next unless entry.transaction? && transaction_ids.include?(entry.entryable_id)
|
||||
|
||||
transfers.each do |transfer|
|
||||
if transfer.inflow_transaction_id == entry.entryable_id ||
|
||||
transfer.outflow_transaction_id == entry.entryable_id
|
||||
result[entry.date] << transfer
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Remove duplicates
|
||||
result.transform_values(&:uniq)
|
||||
end
|
||||
end
|
||||
|
||||
def transaction_ids
|
||||
@transaction_ids ||= entries
|
||||
.select(&:transaction?)
|
||||
.map(&:entryable_id)
|
||||
.compact
|
||||
end
|
||||
end
|
||||
56
app/models/account/anchorable.rb
Normal file
56
app/models/account/anchorable.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
# All accounts are "anchored" with start/end valuation records, with transactions,
|
||||
# trades, and reconciliations between them.
|
||||
module Account::Anchorable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include Monetizable
|
||||
|
||||
monetize :opening_balance
|
||||
end
|
||||
|
||||
def set_opening_anchor_balance(**opts)
|
||||
result = opening_balance_manager.set_opening_balance(**opts)
|
||||
sync_later if result.success?
|
||||
result
|
||||
end
|
||||
|
||||
def opening_anchor_date
|
||||
opening_balance_manager.opening_date
|
||||
end
|
||||
|
||||
def opening_anchor_balance
|
||||
opening_balance_manager.opening_balance
|
||||
end
|
||||
|
||||
def has_opening_anchor?
|
||||
opening_balance_manager.has_opening_anchor?
|
||||
end
|
||||
|
||||
def set_current_balance(balance)
|
||||
result = current_balance_manager.set_current_balance(balance)
|
||||
sync_later if result.success?
|
||||
result
|
||||
end
|
||||
|
||||
def current_anchor_balance
|
||||
current_balance_manager.current_balance
|
||||
end
|
||||
|
||||
def current_anchor_date
|
||||
current_balance_manager.current_date
|
||||
end
|
||||
|
||||
def has_current_anchor?
|
||||
current_balance_manager.has_current_anchor?
|
||||
end
|
||||
|
||||
private
|
||||
def opening_balance_manager
|
||||
@opening_balance_manager ||= Account::OpeningBalanceManager.new(self)
|
||||
end
|
||||
|
||||
def current_balance_manager
|
||||
@current_balance_manager ||= Account::CurrentBalanceManager.new(self)
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
141
app/models/account/current_balance_manager.rb
Normal file
141
app/models/account/current_balance_manager.rb
Normal file
@@ -0,0 +1,141 @@
|
||||
class Account::CurrentBalanceManager
|
||||
InvalidOperation = Class.new(StandardError)
|
||||
|
||||
Result = Struct.new(:success?, :changes_made?, :error, keyword_init: true)
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def has_current_anchor?
|
||||
current_anchor_valuation.present?
|
||||
end
|
||||
|
||||
# Our system should always make sure there is a current anchor, and that it is up to date.
|
||||
# The fallback is provided for backwards compatibility, but should not be relied on since account.balance is a "cached/derived" value.
|
||||
def current_balance
|
||||
if current_anchor_valuation
|
||||
current_anchor_valuation.entry.amount
|
||||
else
|
||||
Rails.logger.warn "No current balance anchor found for account #{account.id}. Using cached balance instead, which may be out of date."
|
||||
account.balance
|
||||
end
|
||||
end
|
||||
|
||||
def current_date
|
||||
if current_anchor_valuation
|
||||
current_anchor_valuation.entry.date
|
||||
else
|
||||
Date.current
|
||||
end
|
||||
end
|
||||
|
||||
def set_current_balance(balance)
|
||||
if account.linked?
|
||||
result = set_current_balance_for_linked_account(balance)
|
||||
else
|
||||
result = set_current_balance_for_manual_account(balance)
|
||||
end
|
||||
|
||||
# Update cache field so changes appear immediately to the user
|
||||
account.update!(balance: balance)
|
||||
|
||||
result
|
||||
rescue => e
|
||||
Result.new(success?: false, changes_made?: false, error: e.message)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account
|
||||
|
||||
def opening_balance_manager
|
||||
@opening_balance_manager ||= Account::OpeningBalanceManager.new(account)
|
||||
end
|
||||
|
||||
def reconciliation_manager
|
||||
@reconciliation_manager ||= Account::ReconciliationManager.new(account)
|
||||
end
|
||||
|
||||
# Manual accounts do not manage the `current_anchor` valuation (otherwise, user would need to continually update it, which is bad UX)
|
||||
# Instead, we use a combination of "auto-update strategies" to set the current balance according to the user's intent.
|
||||
#
|
||||
# The "auto-update strategies" are:
|
||||
# 1. Value tracking - If the account has a reconciliation already, we assume they are tracking the account value primarily with reconciliations, so we append a new one
|
||||
# 2. Transaction adjustment - If the account doesn't have recons, we assume user is tracking with transactions, so we adjust the opening balance with a delta until it
|
||||
# gets us to the desired balance. This ensures we don't append unnecessary reconciliations to the account, which "reset" the value from that
|
||||
# date forward (not user's intent).
|
||||
#
|
||||
# For more documentation on these auto-update strategies, see the test cases.
|
||||
def set_current_balance_for_manual_account(balance)
|
||||
# If we're dealing with a cash account that has no reconciliations, use "Transaction adjustment" strategy (update opening balance to "back in" to the desired current balance)
|
||||
if account.balance_type == :cash && account.valuations.reconciliation.empty?
|
||||
adjust_opening_balance_with_delta(new_balance: balance, old_balance: account.balance)
|
||||
else
|
||||
existing_reconciliation = account.entries.valuations.find_by(date: Date.current)
|
||||
|
||||
result = reconciliation_manager.reconcile_balance(balance: balance, date: Date.current, existing_valuation_entry: existing_reconciliation)
|
||||
|
||||
# Normalize to expected result format
|
||||
Result.new(success?: result.success?, changes_made?: true, error: result.error_message)
|
||||
end
|
||||
end
|
||||
|
||||
def adjust_opening_balance_with_delta(new_balance:, old_balance:)
|
||||
delta = new_balance - old_balance
|
||||
|
||||
result = opening_balance_manager.set_opening_balance(balance: account.opening_anchor_balance + delta)
|
||||
|
||||
# Normalize to expected result format
|
||||
Result.new(success?: result.success?, changes_made?: true, error: result.error)
|
||||
end
|
||||
|
||||
# Linked accounts manage "current balance" via the special `current_anchor` valuation.
|
||||
# This is NOT a user-facing feature, and is primarily used in "processors" while syncing
|
||||
# linked account data (e.g. via Plaid)
|
||||
def set_current_balance_for_linked_account(balance)
|
||||
if current_anchor_valuation
|
||||
changes_made = update_current_anchor(balance)
|
||||
Result.new(success?: true, changes_made?: changes_made, error: nil)
|
||||
else
|
||||
create_current_anchor(balance)
|
||||
Result.new(success?: true, changes_made?: true, error: nil)
|
||||
end
|
||||
end
|
||||
|
||||
def current_anchor_valuation
|
||||
@current_anchor_valuation ||= account.valuations.current_anchor.includes(:entry).first
|
||||
end
|
||||
|
||||
def create_current_anchor(balance)
|
||||
account.entries.create!(
|
||||
date: Date.current,
|
||||
name: Valuation.build_current_anchor_name(account.accountable_type),
|
||||
amount: balance,
|
||||
currency: account.currency,
|
||||
entryable: Valuation.new(kind: "current_anchor")
|
||||
)
|
||||
end
|
||||
|
||||
def update_current_anchor(balance)
|
||||
changes_made = false
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
# Update associated entry attributes
|
||||
entry = current_anchor_valuation.entry
|
||||
|
||||
if entry.amount != balance
|
||||
entry.amount = balance
|
||||
changes_made = true
|
||||
end
|
||||
|
||||
if entry.date != Date.current
|
||||
entry.date = Date.current
|
||||
changes_made = true
|
||||
end
|
||||
|
||||
entry.save! if entry.changed?
|
||||
end
|
||||
|
||||
changes_made
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user