Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fa3698823 | ||
|
|
88a6373e84 | ||
|
|
52d170e36c | ||
|
|
2bc3887262 | ||
|
|
0da057b792 | ||
|
|
0e1c902b63 | ||
|
|
bb0f0239fb | ||
|
|
e6c1c5f368 | ||
|
|
02bbeeaec5 | ||
|
|
1649e991b4 | ||
|
|
7096eefa2b | ||
|
|
4c72231312 | ||
|
|
d86ccd36b6 | ||
|
|
02bfa9f251 | ||
|
|
f2020a816a | ||
|
|
5f2a031d4c | ||
|
|
939244bd3e | ||
|
|
0a17b84566 | ||
|
|
5cf758bd03 | ||
|
|
6331788b33 | ||
|
|
83bee295ca | ||
|
|
dc17a0a298 | ||
|
|
29f445d75e | ||
|
|
9fadfe074b | ||
|
|
2a505b000c | ||
|
|
36a66baf00 | ||
|
|
67716f3006 | ||
|
|
1061aacb0f | ||
|
|
2f6b11c18f | ||
|
|
8e6b81af77 | ||
|
|
9f062de6b4 | ||
|
|
3dfdd0aea5 | ||
|
|
86431e79a3 | ||
|
|
54f5a44a60 | ||
|
|
b41897b5e5 | ||
|
|
f8d64561cf | ||
|
|
5a8074c7ee | ||
|
|
9122eafd31 | ||
|
|
19cc63c8f4 | ||
|
|
a7db914005 | ||
|
|
06468a05b1 | ||
|
|
087dd720c1 | ||
|
|
78baf2b327 | ||
|
|
56203b04d3 | ||
|
|
f65b93a352 | ||
|
|
dd75cadebc | ||
|
|
ed55ef624b | ||
|
|
f363fd4a4e | ||
|
|
b8a3ca7732 | ||
|
|
7b751ac7ca | ||
|
|
15d59959cf | ||
|
|
c66401dc0f | ||
|
|
9dcb9e8ed2 | ||
|
|
045fa1931c | ||
|
|
3f8351abfe | ||
|
|
dc44da6c00 | ||
|
|
2e4180fbf0 | ||
|
|
4b19ca50eb | ||
|
|
a3cd5f4f1d | ||
|
|
86bf47a32e | ||
|
|
5f8a3c9f50 | ||
|
|
eac5d5e663 | ||
|
|
26762477a3 | ||
|
|
372b64ffea | ||
|
|
9627a6bf6f | ||
|
|
cffafd23f0 | ||
|
|
f7fa8fa085 | ||
|
|
28bfcda50a | ||
|
|
e49bda4a2e | ||
|
|
071ad52c7f | ||
|
|
381e39bea8 | ||
|
|
eaa1b6abe0 | ||
|
|
e384369cfb | ||
|
|
8d0509fda0 | ||
|
|
d66c37939a | ||
|
|
cf59fe45e7 | ||
|
|
0544089710 | ||
|
|
5b2fa3d707 | ||
|
|
cf0e573533 | ||
|
|
4e96ca8376 | ||
|
|
c5da8ea550 | ||
|
|
e907b073ed | ||
|
|
4c4a4026c4 | ||
|
|
c95bb082a9 | ||
|
|
4d0df9b950 | ||
|
|
7c66f16750 | ||
|
|
fa0248056d | ||
|
|
624faa10d0 | ||
|
|
9138bd2b76 | ||
|
|
882857fcf0 | ||
|
|
d6793dec05 | ||
|
|
e771c8c1df | ||
|
|
58cc09f5ae | ||
|
|
98c842d3b8 | ||
|
|
fae781e1be | ||
|
|
8208722247 | ||
|
|
f7064fd4dd | ||
|
|
c610b0ba4b | ||
|
|
a4874815a6 | ||
|
|
763e222cdd | ||
|
|
e8390a68d8 | ||
|
|
0e76d753bd | ||
|
|
f5ff5332d5 | ||
|
|
0dea36ec7d | ||
|
|
95989a6c9b |
23
.cursor/rules/general-rules.mdc
Normal file
23
.cursor/rules/general-rules.mdc
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
description: Miscellaneous rules to get the AI to behave
|
||||
globs: *
|
||||
alwaysApply: true
|
||||
---
|
||||
# General rules for AI
|
||||
|
||||
- Use `Current.user` for the current user. Do NOT use `current_user`.
|
||||
- Use `Current.family` for the current family. Do NOT use `current_family`.
|
||||
- Prior to generating any code, carefully read the project conventions and guidelines
|
||||
- Read [project-design.mdc](mdc:.cursor/rules/project-design.mdc) to understand the codebase
|
||||
- Read [project-conventions.mdc](mdc:.cursor/rules/project-conventions.mdc) to understand _how_ to write code for the codebase
|
||||
- Read [ui-ux-design-guidelines.mdc](mdc:.cursor/rules/ui-ux-design-guidelines.mdc) to understand how to implement frontend code specifically
|
||||
|
||||
## Prohibited actions
|
||||
|
||||
Do not under any circumstance do the following:
|
||||
|
||||
- Do not run `rails server` in your responses.
|
||||
- Do not run `touch tmp/restart.txt`
|
||||
- Do not run `rails credentials`
|
||||
- Do not automatically run migrations
|
||||
- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development.
|
||||
@@ -1,14 +1,9 @@
|
||||
---
|
||||
description: This rule explains the project's tech stack and code conventions
|
||||
globs: *
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
This rule serves as high-level documentation for how the Maybe codebase is structured.
|
||||
|
||||
## Rules for AI
|
||||
|
||||
- Use this file to understand how the codebase works
|
||||
- Treat this rule/file as your "source of truth" when making code recommendations
|
||||
- When creating migrations, always use `rails g migration` instead of creating the file yourself
|
||||
This rule serves as high-level documentation for how you should write code for the Maybe codebase.
|
||||
|
||||
## Project Tech Stack
|
||||
|
||||
@@ -18,8 +13,9 @@ This rule serves as high-level documentation for how the Maybe codebase is struc
|
||||
- Hotwire Turbo/Stimulus for SPA-like UI/UX
|
||||
- TailwindCSS for styles
|
||||
- Lucide Icons for icons
|
||||
- OpenAI for AI chat
|
||||
- Database: PostgreSQL
|
||||
- Jobs: GoodJob
|
||||
- Jobs: Sidekiq + Redis
|
||||
- External
|
||||
- Payments: Stripe
|
||||
- User bank data syncing: Plaid
|
||||
@@ -46,39 +42,79 @@ This codebase adopts a "skinny controller, fat models" convention. Furthermore,
|
||||
- When concerns are used for code organization, they should be organized around the "traits" of a model; not for simply moving code to another spot in the codebase.
|
||||
- When possible, models should answer questions about themselves—for example, we might have a method, `account.balance_series` that returns a time-series of the account's most recent balances. We prefer this over something more service-like such as `AccountSeries.new(account).call`.
|
||||
|
||||
### Convention 3: Prefer server-side solutions over client-side solutions
|
||||
### Convention 3: Leverage Hotwire, write semantic HTML, CSS, and JS, prefer server-side solutions
|
||||
|
||||
- When possible, leverage Turbo frames over complex, JS-driven client-side solutions
|
||||
- When writing a client-side solution, use Stimulus controllers and keep it simple!
|
||||
- Especially when dealing with money and currencies, calculate + format server-side and then pass that to the client to display
|
||||
- Keep client-side code for where it truly shines. For example, [bulk_select_controller.js](mdc:app/javascript/controllers/bulk_select_controller.js) is a case where server-side solutions would degrade the user experience significantly. When bulk-selecting entries, client-side solutions are the way to go and Stimulus provides the right toolset to achieve this.
|
||||
|
||||
### Convention 4: Sacrifice performance, optimize for simplicitly and clarity
|
||||
|
||||
This codebase is still young. We are still rapidly iterating on domain designs and features. Because of this, code should be optimized for simplicitly and clarity over performance.
|
||||
|
||||
- Focus on good OOP design first, performance second
|
||||
- Be mindful of large performance bottlenecks, but don't sweat the small stuff
|
||||
|
||||
### Convention 5: Prefer semantic, native HTML features
|
||||
|
||||
The HTML spec has improved tremendously over the years and offers a ton of functionality out of the box. We prefer semantic, native HTML solutions over JS-based ones. A few examples of this include:
|
||||
|
||||
- Using the `dialog` element for modals
|
||||
- Using `summary` / `details` elements for disclosures (or `popover` attribute)
|
||||
- Native HTML is always preferred over JS-based components
|
||||
- Example 1: Use `<dialog>` element for modals instead of creating a custom component
|
||||
- Example 2: Use `<details><summary>...</summary></details>` for disclosures rather than custom components
|
||||
- Leverage Turbo frames to break up the page over JS-driven client-side solutions
|
||||
- Example 1: A good example of turbo frame usage is in [application.html.erb](mdc:app/views/layouts/application.html.erb) where we load [chats_controller.rb](mdc:app/controllers/chats_controller.rb) actions in a turbo frame in the global layout
|
||||
- Leverage query params in the URL for state over local storage and sessions. If absolutely necessary, utilize the DB for persistent state.
|
||||
- Use Turbo streams to enhance functionality, but do not solely depend on it
|
||||
- Format currencies, numbers, dates, and other values server-side, then pass to Stimulus controllers for display only
|
||||
- Keep client-side code for where it truly shines. For example, @bulk_select_controller.js is a case where server-side solutions would degrade the user experience significantly. When bulk-selecting entries, client-side solutions are the way to go and Stimulus provides the right toolset to achieve this.
|
||||
|
||||
The Hotwire suite (Turbo/Stimulus) works very well with these native elements and we optimize for this.
|
||||
|
||||
### Convention 6: Use Minitest + Fixtures for testing, minimize fixtures
|
||||
### Convention 4: Optimize for simplicitly and clarity
|
||||
|
||||
All code should maximize readability and simplicity.
|
||||
|
||||
- Prioritize good OOP domain design over performance
|
||||
- Only focus on performance for critical and global areas of the codebase; otherwise, don't sweat the small stuff.
|
||||
- Example 1: be mindful of loading large data payloads in global layouts
|
||||
- Example 2: Avoid N+1 queries
|
||||
|
||||
### Convention 5: Use Minitest + Fixtures for testing, minimize fixtures
|
||||
|
||||
Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability.
|
||||
|
||||
- Always use Minitest and fixtures for testing.
|
||||
- Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed.
|
||||
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [balance_calculator_test.rb](mdc:test/models/account/balance_calculator_test.rb)
|
||||
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb)
|
||||
- Take a minimal approach to testing—only test the absolutely critical code paths that will significantly increase developer confidence
|
||||
|
||||
### Convention 7: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
|
||||
#### Convention 5a: Write minimal, effective tests
|
||||
|
||||
- Use system tests sparingly as they increase the time to complete the test suite
|
||||
- Only write tests for critical and important code paths
|
||||
- Write tests as you go, when required
|
||||
- Take a practical approach to testing. Tests are effective when their presence _significantly increases confidence in the codebase_.
|
||||
|
||||
Below are examples of necessary vs. unnecessary tests:
|
||||
|
||||
```rb
|
||||
# GOOD!!
|
||||
# Necessary test - in this case, we're testing critical domain business logic
|
||||
test "syncs balances" do
|
||||
Account::Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
|
||||
|
||||
@account.expects(:start_date).returns(2.days.ago.to_date)
|
||||
|
||||
Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||
[
|
||||
Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
||||
Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
||||
]
|
||||
)
|
||||
|
||||
assert_difference "@account.balances.count", 2 do
|
||||
Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
||||
end
|
||||
end
|
||||
|
||||
# BAD!!
|
||||
# Unnecessary test - in this case, this is simply testing ActiveRecord's functionality
|
||||
test "saves balance" do
|
||||
balance_record = Account::Balance.new(balance: 100, currency: "USD")
|
||||
|
||||
assert balance_record.save
|
||||
end
|
||||
```
|
||||
|
||||
### Convention 6: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
|
||||
|
||||
- Enforce `null` checks, unique indexes, and other simple validations in the DB
|
||||
- ActiveRecord validations _may_ mirror the DB level ones, but not 100% necessary. These are for convenience when error handling in forms. Always prefer client-side form validation when possible.
|
||||
- Complex validations and business logic should remain in ActiveRecord
|
||||
- Complex validations and business logic should remain in ActiveRecord
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
description: This rule explains the system architecture and data flow of the Rails app
|
||||
globs: *
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
This file outlines how the codebase is structured and how data flows through the app.
|
||||
@@ -110,12 +111,12 @@ Below are brief descriptions of each type of sync in more detail.
|
||||
|
||||
### Account Syncs
|
||||
|
||||
The most important type of sync is the account sync. It is orchestrated by the account [syncer.rb](mdc:app/models/account/syncer.rb), and performs a few important tasks:
|
||||
The most important type of sync is the account sync. It is orchestrated by the account's `sync_data` method, which performs a few important tasks:
|
||||
|
||||
- Auto-matches transfer records for the account
|
||||
- Calculates holdings and balances for the account
|
||||
- Enriches transaction data
|
||||
- Converts account balances that are not in the family's preferred currency to the preferred currency
|
||||
- Calculates daily [balance.rb](mdc:app/models/account/balance.rb) records for the account from `account.start_date` to `Date.current` using [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb)
|
||||
- Balances are dependent on the calculation of [holding.rb](mdc:app/models/account/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb)
|
||||
- Enriches transaction data if enabled by user
|
||||
|
||||
An account sync happens every time an [entry.rb](mdc:app/models/account/entry.rb) is updated.
|
||||
|
||||
@@ -131,4 +132,125 @@ A Plaid Item sync is an ETL (extract, transform, load) operation:
|
||||
|
||||
A family sync happens once daily via [auto_sync.rb](mdc:app/controllers/concerns/auto_sync.rb). A family sync is an "orchestrator" of Account and Plaid Item syncs.
|
||||
|
||||
## Data Providers
|
||||
|
||||
The Maybe app utilizes several 3rd party data services to calculate historical account balances, enrich data, and more. Since the app can be run in both "hosted" and "self hosted" mode, this means that data providers are _optional_ for self hosted users and must be configured.
|
||||
|
||||
Because of this optionality, data providers must be configured at _runtime_ through [registry.rb](mdc:app/models/provider/registry.rb) utilizing [setting.rb](mdc:app/models/setting.rb) for runtime parameters like API keys:
|
||||
|
||||
There are two types of 3rd party data in the Maybe app:
|
||||
|
||||
1. "Concept" data
|
||||
2. One-off data
|
||||
|
||||
### "Concept" data
|
||||
|
||||
Since the app is self hostable, users may prefer using different providers for generic data like exchange rates and security prices. When data is generic enough where we can easily swap out different providers, we call it a data "concept".
|
||||
|
||||
Each "concept" has an interface defined in the `app/models/provider/concepts` directory.
|
||||
|
||||
```
|
||||
app/models/
|
||||
exchange_rate/
|
||||
provided.rb # <- Responsible for selecting the concept provider from the registry
|
||||
provider.rb # <- Base provider class
|
||||
provider/
|
||||
registry.rb <- Defines available providers by concept
|
||||
concepts/
|
||||
exchange_rate.rb <- defines the interface required for the exchange rate concept
|
||||
synth.rb # <- Concrete provider implementation
|
||||
```
|
||||
|
||||
### One-off data
|
||||
|
||||
For data that does not fit neatly into a "concept", an interface is not required and the concrete provider may implement ad-hoc methods called directly in code. For example, the [synth.rb](mdc:app/models/provider/synth.rb) provider has a `usage` method that is only applicable to this specific provider. This should be called directly without any abstractions:
|
||||
|
||||
```rb
|
||||
class SomeModel < Application
|
||||
def synth_usage
|
||||
Provider::Registry.get_provider(:synth)&.usage
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## "Provided" Concerns
|
||||
|
||||
In general, domain models should not be calling [registry.rb](mdc:app/models/provider/registry.rb) directly. When 3rd party data is required for a domain model, we use the `Provided` concern within that model's namespace. This concern is primarily responsible for:
|
||||
|
||||
- Choosing the provider to use for this "concept"
|
||||
- Providing convenience methods on the model for accessing data
|
||||
|
||||
For example, [exchange_rate.rb](mdc:app/models/exchange_rate.rb) has a [provided.rb](mdc:app/models/exchange_rate/provided.rb) concern with the following convenience methods:
|
||||
|
||||
```rb
|
||||
module ExchangeRate::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def provider
|
||||
registry = Provider::Registry.for_concept(:exchange_rates)
|
||||
registry.get_provider(:synth)
|
||||
end
|
||||
|
||||
def find_or_fetch_rate(from:, to:, date: Date.current, cache: true)
|
||||
# Implementation
|
||||
end
|
||||
|
||||
def sync_provider_rates(from:, to:, start_date:, end_date: Date.current)
|
||||
# Implementation
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
This exposes a generic access pattern where the caller does not care _which_ provider has been chosen for the concept of exchange rates and can get a predictable response:
|
||||
|
||||
```rb
|
||||
def access_patterns_example
|
||||
# Call exchange rate provider directly
|
||||
ExchangeRate.provider.fetch_exchange_rate(from: "USD", to: "CAD", date: Date.current)
|
||||
|
||||
# Call convenience method
|
||||
ExchangeRate.sync_provider_rates(from: "USD", to: "CAD", start_date: 2.days.ago.to_date)
|
||||
end
|
||||
```
|
||||
|
||||
## Concrete provider implementations
|
||||
|
||||
Each 3rd party data provider should have a class under the `Provider::` namespace that inherits from `Provider` and returns `with_provider_response`, which will return a `Provider::ProviderResponse` object:
|
||||
|
||||
```rb
|
||||
class ConcreteProvider < Provider
|
||||
def fetch_some_data
|
||||
with_provider_response do
|
||||
ExampleData.new(
|
||||
example: "data"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
The `with_provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible:
|
||||
|
||||
```rb
|
||||
class ConcreteProvider < Provider
|
||||
def fetch_some_data
|
||||
with_provider_response do
|
||||
data = nil
|
||||
|
||||
# Raise an error if data cannot be returned
|
||||
raise ProviderError.new("Could not find the data you need") if data.nil?
|
||||
|
||||
data
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
---
|
||||
description: This file describes Maybe's design system and how views should be styled
|
||||
globs: app/views/**,app/helpers/**,app/javascript/controllers/**
|
||||
alwaysApply: true
|
||||
---
|
||||
Use this rule whenever you are writing html, css, or even styles in Stimulus controllers that use D3.js.
|
||||
Use the rules below when:
|
||||
|
||||
- You are writing HTML
|
||||
- You are writing CSS
|
||||
- You are writing styles in a JavaScript Stimulus controller
|
||||
|
||||
## Rules for AI (mandatory)
|
||||
|
||||
The codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css)
|
||||
|
||||
- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives and tokens we use in the codebase
|
||||
- Always generate semantic HTML
|
||||
- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase
|
||||
- Always prefer using the functional "tokens" defined in @maybe-design-system.css when possible.
|
||||
- Example 1: use `text-primary` rather than `text-primary`
|
||||
- Example 2: use `bg-container` rather than `bg-white`
|
||||
- Example 3: use `border border-primary` rather than `border border-gray-200`
|
||||
- Never create new styles in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) or [application.css](mdc:app/assets/tailwind/application.css) without explicitly receiving permission to do so
|
||||
- Always favor the "utility first" Tailwind approach. Reusable style classes should not be created often. Code should be reused primarily through ERB partials.
|
||||
- Always prefer using the utility "tokens" defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) when possible. For example, use `text-primary` rather than `text-gray-900`.
|
||||
- Always generate semantic HTML
|
||||
@@ -1,4 +1,15 @@
|
||||
version: "3"
|
||||
x-db-env: &db_env
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
||||
x-rails-env: &rails_env
|
||||
DB_HOST: db
|
||||
HOST: "0.0.0.0"
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
BUNDLE_PATH: /bundle
|
||||
REDIS_URL: redis://redis:6379/1
|
||||
|
||||
services:
|
||||
app:
|
||||
@@ -16,32 +27,41 @@ services:
|
||||
command: sleep infinity
|
||||
|
||||
environment:
|
||||
DB_HOST: db
|
||||
HOST: "0.0.0.0"
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
BUNDLE_PATH: /bundle
|
||||
<<: *rails_env
|
||||
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
command: bundle exec sidekiq
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: *rails_env
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
redis:
|
||||
image: redis:latest
|
||||
ports:
|
||||
- "6379:6379"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
db:
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
<<: *db_env
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
bundle_cache:
|
||||
|
||||
96
.env.example
96
.env.example
@@ -1,20 +1,31 @@
|
||||
# ================================ PLEASE READ ==========================================
|
||||
# This file outlines all the possible environment variables supported by the Maybe app.
|
||||
#
|
||||
# This includes several features that are for our "hosted" version of Maybe, which most
|
||||
# open-source contributors won't need.
|
||||
# ================================ PLEASE READ ===========================================================
|
||||
# This file outlines all the possible environment variables supported by the Maybe app for self hosting.
|
||||
#
|
||||
# If you are developing locally, you should be referencing `.env.local.example` instead.
|
||||
# =======================================================================================
|
||||
# If you're a developer setting up your local environment, please use `.env.local.example` instead.
|
||||
# ========================================================================================================
|
||||
|
||||
# Required self-hosting vars
|
||||
# --------------------------------------------------------------------------------------------------------
|
||||
|
||||
# Enables self hosting features (should be set to true unless you know what you're doing)
|
||||
SELF_HOSTED=true
|
||||
|
||||
# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base)
|
||||
# Has to be a random string, generated eg. by running `openssl rand -hex 64`
|
||||
SECRET_KEY_BASE=secret-value
|
||||
|
||||
# Optional self-hosting vars
|
||||
# --------------------------------------------------------------------------------------------------------
|
||||
|
||||
# Optional: Synth API Key for exchange rates + stock prices
|
||||
# (you can also set this in your self-hosted settings page)
|
||||
# Get it here: https://synthfinance.com/
|
||||
SYNTH_API_KEY=
|
||||
|
||||
# Custom port config
|
||||
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
|
||||
PORT=3000
|
||||
|
||||
# Exchange Rate & Stock Pricing API
|
||||
# This is used to convert between different currencies in the app. In addition, it fetches global stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
|
||||
SYNTH_API_KEY=
|
||||
|
||||
# SMTP Configuration
|
||||
# This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports).
|
||||
# Resend.com is a good option that offers a free tier for sending emails.
|
||||
@@ -37,60 +48,20 @@ POSTGRES_USER=postgres
|
||||
# This is the domain that your Maybe instance will be hosted at. It is used to generate links in emails and other places.
|
||||
APP_DOMAIN=
|
||||
|
||||
## Error and Performance Monitoring
|
||||
# The app uses Sentry to monitor errors and performance. In reality, you likely don't need this unless you're deploying Maybe to many users.
|
||||
SENTRY_DSN=
|
||||
|
||||
# If enabled, an invite code generated by `rake invites:create` is required to sign up as a new user.
|
||||
# This is useful for controlling who can sign up for your Maybe instance.
|
||||
REQUIRE_INVITE_CODE=false
|
||||
|
||||
# Enables self hosting features (should be set to true for most folks)
|
||||
SELF_HOSTED=true
|
||||
|
||||
# The hosting platform used to deploy the app (e.g. "render")
|
||||
# `localhost` (or unset) is used for local development and testing
|
||||
HOSTING_PLATFORM=localhost
|
||||
|
||||
# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base)
|
||||
# Has to be a random string, generated eg. by running `openssl rand -hex 64`
|
||||
SECRET_KEY_BASE=secret-value
|
||||
|
||||
# Disable enforcing SSL connections
|
||||
# DISABLE_SSL=true
|
||||
|
||||
# ======================================================================================================
|
||||
# Upgrades Module - responsible for triggering upgrade alerts, prompts, and auto-upgrade functionality
|
||||
# ======================================================================================================
|
||||
#
|
||||
# UPGRADES_ENABLED: Enables Upgrader class functionality.
|
||||
# UPGRADES_MODE: Controls how the app will upgrade. `manual` means the user must manually upgrade the app. `auto` means the app will upgrade automatically (great for self-hosting)
|
||||
# UPGRADES_TARGET: Controls what the app will upgrade to. `release` means the app will upgrade to the latest release. `commit` means the app will upgrade to the latest commit.
|
||||
#
|
||||
UPGRADES_ENABLED=false # unless editing the flow, you should keep this `false` locally in development
|
||||
UPGRADES_MODE=manual # `manual` or `auto`
|
||||
UPGRADES_TARGET=release # `release` or `commit`
|
||||
|
||||
|
||||
# ======================================================================================================
|
||||
# Git Repository Module - responsible for fetching latest commit data for upgrades
|
||||
# ======================================================================================================
|
||||
#
|
||||
GITHUB_REPO_OWNER=maybe-finance
|
||||
GITHUB_REPO_NAME=maybe
|
||||
GITHUB_REPO_BRANCH=main
|
||||
|
||||
# ======================================================================================================
|
||||
# Active Storage Configuration - responsible for storing file uploads
|
||||
# ======================================================================================================
|
||||
#
|
||||
# * Defaults to disk storage but you can also use Amazon S3, Google Cloud Storage, or Microsoft Azure Storage.
|
||||
# * Defaults to disk storage but you can also use Amazon S3 or Cloudflare R2
|
||||
# * Set the appropriate environment variables to use these services.
|
||||
# * Ensure libvips is installed on your system for image processing - https://github.com/libvips/libvips
|
||||
#
|
||||
# Amazon S3
|
||||
# ==========
|
||||
# ACTIVE_STORAGE_SERVICE=amazon
|
||||
# ACTIVE_STORAGE_SERVICE=amazon <- Enables Amazon S3 storage
|
||||
# S3_ACCESS_KEY_ID=
|
||||
# S3_SECRET_ACCESS_KEY=
|
||||
# S3_REGION= # defaults to `us-east-1` if not set
|
||||
@@ -98,26 +69,9 @@ GITHUB_REPO_BRANCH=main
|
||||
#
|
||||
# Cloudflare R2
|
||||
# =============
|
||||
# ACTIVE_STORAGE_SERVICE=cloudflare
|
||||
# ACTIVE_STORAGE_SERVICE=cloudflare <- Enables Cloudflare R2 storage
|
||||
# CLOUDFLARE_ACCOUNT_ID=
|
||||
# CLOUDFLARE_ACCESS_KEY_ID=
|
||||
# CLOUDFLARE_SECRET_ACCESS_KEY=
|
||||
# CLOUDFLARE_BUCKET=
|
||||
|
||||
# ======================================================================================================
|
||||
# Billing Module - responsible for handling billing
|
||||
# ======================================================================================================
|
||||
#
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# ======================================================================================================
|
||||
# Plaid Configuration
|
||||
# ======================================================================================================
|
||||
#
|
||||
PLAID_CLIENT_ID=
|
||||
PLAID_SECRET=
|
||||
PLAID_ENV=
|
||||
PLAID_EU_CLIENT_ID=
|
||||
PLAID_EU_SECRET=
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
SELF_HOSTED=false
|
||||
SYNTH_API_KEY=fookey
|
||||
|
||||
# Set to true if you want SimpleCov reports generated
|
||||
COVERAGE=false
|
||||
|
||||
# Set to true to run test suite serially
|
||||
DISABLE_PARALLELIZATION=false
|
||||
@@ -1,3 +1,5 @@
|
||||
SELF_HOSTED=false
|
||||
|
||||
# ================
|
||||
# Data Providers
|
||||
# ---------------------------------------------------------------------------------
|
||||
|
||||
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,31 +1,61 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
about: Open a bug report when you experience broken functionality within the latest
|
||||
version of the Maybe app
|
||||
title: 'Bug: [Add descriptive title here]'
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Where did this bug occur? (required)**
|
||||
## Before you start (required)
|
||||
|
||||
- [ ] I am a self-hosted user reporting a bug from my self hosted app
|
||||
- [ ] I am a user of Maybe's paid app
|
||||
### General checklist
|
||||
|
||||
_Please note, if you are reporting a bug with sensitive data, please open an Intercom chat from within the app for help_
|
||||
- [ ] I have removed personal / sensitive data from screenshots and logs
|
||||
- [ ] I have searched [existing issues](https://github.com/maybe-finance/maybe/issues?q=is:issue) and [discussions](https://github.com/maybe-finance/maybe/discussions) to ensure this is not a duplicate issue
|
||||
|
||||
### How are you using Maybe?
|
||||
|
||||
- [ ] I am a paying Maybe customer (hosted version)
|
||||
- Paying Maybe users can also open requests in Intercom (if there is sensitive info involved)
|
||||
- [ ] I am a self-hosted user
|
||||
|
||||
### Self hoster checklist
|
||||
|
||||
_Paying, hosted users should delete this entire section._
|
||||
|
||||
If you are a self-hosted user, please complete all of the information below. Issues with incomplete information will be marked as `Needs Info` to help our small team prioritize bug fixes.
|
||||
|
||||
- Self hosted app commit SHA (find in user menu): [enter commit sha here]
|
||||
- [ ] I have confirmed that my app's commit is the latest version of Maybe
|
||||
- Where are you hosting?
|
||||
- [ ] Render
|
||||
- [ ] Docker Compose
|
||||
- [ ] Umbrel
|
||||
- [ ] Other (please specify)
|
||||
|
||||
---
|
||||
|
||||
## Bug description
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
### To Reproduce
|
||||
|
||||
Be as specific as possible so Maybe maintainers can quickly reproduce the bug you're experiencing.
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
### Expected behavior
|
||||
|
||||
**Screenshots / Recordings**
|
||||
If applicable, add screenshots or short video recordings to help show the bug in more detail.
|
||||
What is the intended behavior that you would expect?
|
||||
|
||||
### Screenshots and/or recordings
|
||||
|
||||
We highly recommend providing additional context with screenshots and/or screen recordings. This will _significantly_ improve the chances of the bug being addressed and fixed quickly.
|
||||
|
||||
32
.github/ISSUE_TEMPLATE/other.md
vendored
32
.github/ISSUE_TEMPLATE/other.md
vendored
@@ -7,15 +7,33 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**PLEASE READ before opening an issue:**
|
||||
## Before you start (required)
|
||||
|
||||
- Is this a feature request? Please [open a feature request discussion](https://github.com/maybe-finance/maybe/discussions/new?category=feature-requests).
|
||||
- Do you need help or have a question? Please [open a discussion](https://github.com/maybe-finance/maybe/discussions/new/choose) or [join our Discord](https://link.maybe.co/discord) and post to the "help" channel.
|
||||
### Is this a bug?
|
||||
|
||||
----------------------
|
||||
A bug is _broken functionality_ of the app (i.e. it prevents you from using the app). For bugs, please use the ["Bug Report" template](https://github.com/maybe-finance/maybe/issues) instead.
|
||||
|
||||
**Is this issue related to a problem? Please describe.**
|
||||
### Is this a bug with _sensitive info_?
|
||||
|
||||
**Describe the work that needs to be done to address this issue**
|
||||
If you are a _paying_ Maybe user, you can open a support request in Intercom.
|
||||
|
||||
**Additional context**
|
||||
### Is this a feature request?
|
||||
|
||||
A feature request is functionality that you would like that is not already on our [Roadmap](https://github.com/maybe-finance/maybe/wiki/Roadmap).
|
||||
|
||||
All feature requests should be opened in a [Feature request Discussion](https://github.com/maybe-finance/maybe/discussions/categories/feature-requests).
|
||||
|
||||
Be sure to search existing discussions prior to opening a new feature request.
|
||||
|
||||
### Is this related to Docker and/or hosting for self hosting?
|
||||
|
||||
If you are having a Docker configuration issue, please do not open a Github issue unless you've identified a bug in our Dockerfile. To get help with self hosting, there are several options:
|
||||
|
||||
- **First**: Read our [Docker hosting guide](https://github.com/maybe-finance/maybe/tree/main/docs/hosting/docker.md) and follow it step-by-step
|
||||
- Open a [Docker Discussion](https://github.com/maybe-finance/maybe/discussions/categories/docker-compose-hosting)
|
||||
|
||||
---
|
||||
|
||||
## Issue description
|
||||
|
||||
If your issue does not fall into the categories above, please provide a **descriptive and complete** overview of your issue.
|
||||
|
||||
10
.github/workflows/publish.yml
vendored
10
.github/workflows/publish.yml
vendored
@@ -1,6 +1,13 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git ref (tag or commit SHA) to build'
|
||||
required: true
|
||||
type: string
|
||||
default: 'main'
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
@@ -33,6 +40,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -73,3 +82,4 @@ jobs:
|
||||
provenance: false
|
||||
# https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images
|
||||
outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=A multi-arch Docker image for the Maybe Rails app
|
||||
build-args: BUILD_COMMIT_SHA=${{ github.sha }}
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -11,7 +11,6 @@
|
||||
# Ignore all environment files (except templates).
|
||||
/.env*
|
||||
!/.env*.erb
|
||||
!.env.test
|
||||
!.env*.example
|
||||
|
||||
# Ignore all logfiles and tempfiles.
|
||||
@@ -63,6 +62,10 @@ gcp-storage-keyfile.json
|
||||
|
||||
coverage
|
||||
.cursorrules
|
||||
.cursor/rules/structure.mdc
|
||||
.cursor/rules/agent.mdc
|
||||
|
||||
# Ignore node related files
|
||||
node_modules
|
||||
node_modules
|
||||
|
||||
compose.yml
|
||||
10
Dockerfile
10
Dockerfile
@@ -9,19 +9,21 @@ WORKDIR /rails
|
||||
|
||||
# Install base packages
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y curl libvips postgresql-client git
|
||||
apt-get install --no-install-recommends -y curl libvips postgresql-client
|
||||
|
||||
# Set production environment
|
||||
ARG BUILD_COMMIT_SHA
|
||||
ENV RAILS_ENV="production" \
|
||||
BUNDLE_DEPLOYMENT="1" \
|
||||
BUNDLE_PATH="/usr/local/bundle" \
|
||||
BUNDLE_WITHOUT="development"
|
||||
|
||||
BUNDLE_WITHOUT="development" \
|
||||
BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA}
|
||||
|
||||
# Throw-away build stage to reduce size of final image
|
||||
FROM base AS build
|
||||
|
||||
# Install packages needed to build gems
|
||||
RUN apt-get install --no-install-recommends -y build-essential libpq-dev pkg-config
|
||||
RUN apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config
|
||||
|
||||
# Install application gems
|
||||
COPY .ruby-version Gemfile Gemfile.lock ./
|
||||
|
||||
13
Gemfile
13
Gemfile
@@ -7,6 +7,7 @@ gem "rails", "~> 7.2.2"
|
||||
|
||||
# Drivers
|
||||
gem "pg", "~> 1.5"
|
||||
gem "redis", "~> 5.4"
|
||||
|
||||
# Deployment
|
||||
gem "puma", ">= 5.0"
|
||||
@@ -22,17 +23,17 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails"
|
||||
gem "stimulus-rails"
|
||||
gem "turbo-rails"
|
||||
|
||||
# Temporary pin to commit to fix crypto.randomUUID() errors. Revert this when the change has been released.
|
||||
gem "hotwire_combobox", github: "josefarias/hotwire_combobox", ref: "b827048a8305e1115d5f96931ba1c9750d1e59fc"
|
||||
gem "hotwire_combobox"
|
||||
|
||||
# Background Jobs
|
||||
gem "good_job"
|
||||
gem "sidekiq"
|
||||
|
||||
# Error logging
|
||||
gem "stackprof"
|
||||
gem "vernier"
|
||||
gem "rack-mini-profiler"
|
||||
gem "sentry-ruby"
|
||||
gem "sentry-rails"
|
||||
gem "sentry-sidekiq"
|
||||
gem "logtail-rails"
|
||||
|
||||
# Active Storage
|
||||
@@ -57,6 +58,10 @@ gem "intercom-rails"
|
||||
gem "plaid"
|
||||
gem "rotp", "~> 6.3"
|
||||
gem "rqrcode", "~> 2.2"
|
||||
gem "activerecord-import"
|
||||
|
||||
# AI
|
||||
gem "ruby-openai"
|
||||
|
||||
group :development, :test do
|
||||
gem "debug", platforms: %i[mri windows]
|
||||
|
||||
213
Gemfile.lock
213
Gemfile.lock
@@ -1,14 +1,3 @@
|
||||
GIT
|
||||
remote: https://github.com/josefarias/hotwire_combobox.git
|
||||
revision: b827048a8305e1115d5f96931ba1c9750d1e59fc
|
||||
ref: b827048a8305e1115d5f96931ba1c9750d1e59fc
|
||||
specs:
|
||||
hotwire_combobox (0.3.2)
|
||||
platform_agent (>= 1.0.1)
|
||||
rails (>= 7.0.7.2)
|
||||
stimulus-rails (>= 1.2)
|
||||
turbo-rails (>= 1.2)
|
||||
|
||||
GIT
|
||||
remote: https://github.com/maybe-finance/lucide-rails.git
|
||||
revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0
|
||||
@@ -72,6 +61,8 @@ GEM
|
||||
activemodel (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
timeout (>= 0.4.0)
|
||||
activerecord-import (2.1.0)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
@@ -92,15 +83,17 @@ GEM
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1043.0)
|
||||
aws-sdk-core (3.217.0)
|
||||
ast (2.4.3)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1073.0)
|
||||
aws-sdk-core (3.221.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.97.0)
|
||||
logger
|
||||
aws-sdk-kms (1.99.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.177.0)
|
||||
@@ -124,7 +117,7 @@ GEM
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.0)
|
||||
brakeman (7.0.2)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
@@ -146,7 +139,7 @@ GEM
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
csv (3.3.2)
|
||||
csv (3.3.3)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
irb (~> 1.10)
|
||||
@@ -165,8 +158,7 @@ GEM
|
||||
rubocop (>= 1)
|
||||
smart_properties
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
event_stream_parser (1.0.0)
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.12.2)
|
||||
@@ -177,7 +169,7 @@ GEM
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-retry (2.2.1)
|
||||
faraday-retry (2.3.1)
|
||||
faraday (~> 2.0)
|
||||
ffi (1.17.1-aarch64-linux-gnu)
|
||||
ffi (1.17.1-aarch64-linux-musl)
|
||||
@@ -187,18 +179,8 @@ GEM
|
||||
ffi (1.17.1-x86_64-darwin)
|
||||
ffi (1.17.1-x86_64-linux-gnu)
|
||||
ffi (1.17.1-x86_64-linux-musl)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.9.0)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (>= 1.11.0)
|
||||
railties (>= 6.1.0)
|
||||
thor (>= 1.0.0)
|
||||
hashdiff (1.1.2)
|
||||
highline (3.1.2)
|
||||
reline
|
||||
@@ -206,9 +188,14 @@ GEM
|
||||
actioncable (>= 7.0.0)
|
||||
listen (>= 3.0.0)
|
||||
railties (>= 7.0.0)
|
||||
hotwire_combobox (0.4.0)
|
||||
platform_agent (>= 1.0.1)
|
||||
rails (>= 7.0.7.2)
|
||||
stimulus-rails (>= 1.2)
|
||||
turbo-rails (>= 1.2)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-tasks (1.0.14)
|
||||
i18n-tasks (1.0.15)
|
||||
activesupport (>= 4.0.2)
|
||||
ast (>= 2.1.0)
|
||||
erubi
|
||||
@@ -217,6 +204,7 @@ GEM
|
||||
parser (>= 3.2.2.1)
|
||||
rails-i18n
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
ruby-progressbar (~> 1.8, >= 1.8.1)
|
||||
terminal-table (>= 1.5.1)
|
||||
image_processing (1.14.0)
|
||||
mini_magick (>= 4.9.5, < 6)
|
||||
@@ -237,21 +225,22 @@ GEM
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.10.1)
|
||||
json (2.10.2)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
language_server-protocol (3.17.0.4)
|
||||
launchy (3.1.0)
|
||||
launchy (3.1.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
lint_roller (1.1.0)
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.6.6)
|
||||
logtail (0.1.15)
|
||||
logger (1.7.0)
|
||||
logtail (0.1.17)
|
||||
msgpack (~> 1.0)
|
||||
logtail-rack (0.2.6)
|
||||
logtail (~> 0.1)
|
||||
@@ -272,49 +261,49 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
mini_magick (5.1.2)
|
||||
mini_magick (5.2.0)
|
||||
benchmark
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.4)
|
||||
minitest (5.25.5)
|
||||
mocha (2.7.1)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.8.0)
|
||||
multipart-post (2.4.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.5)
|
||||
net-imap (0.5.6)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.5.0)
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.2-aarch64-linux-gnu)
|
||||
nokogiri (1.18.6-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-aarch64-linux-musl)
|
||||
nokogiri (1.18.6-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm-linux-gnu)
|
||||
nokogiri (1.18.6-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm-linux-musl)
|
||||
nokogiri (1.18.6-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm64-darwin)
|
||||
nokogiri (1.18.6-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-darwin)
|
||||
nokogiri (1.18.6-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-gnu)
|
||||
nokogiri (1.18.6-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-musl)
|
||||
nokogiri (1.18.6-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
octokit (9.2.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (9.3.3)
|
||||
pagy (9.3.4)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.7.0)
|
||||
parser (3.3.7.2)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
@@ -327,7 +316,7 @@ GEM
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.3.0)
|
||||
prism (1.4.0)
|
||||
propshaft (1.1.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
@@ -339,9 +328,8 @@ GEM
|
||||
public_suffix (6.0.1)
|
||||
puma (6.6.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.10)
|
||||
rack (3.1.12)
|
||||
rack-mini-profiler (3.3.1)
|
||||
rack (>= 1.2.0)
|
||||
rack-session (2.1.0)
|
||||
@@ -391,11 +379,15 @@ GEM
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (3.8.1)
|
||||
rbs (3.9.1)
|
||||
logger
|
||||
rdoc (6.12.0)
|
||||
rdoc (6.13.0)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.0)
|
||||
redcarpet (3.6.1)
|
||||
redis (5.4.0)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.24.0)
|
||||
connection_pool
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
io-console (~> 0.5)
|
||||
@@ -405,41 +397,44 @@ GEM
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 1.0)
|
||||
rqrcode_core (1.2.0)
|
||||
rubocop (1.71.0)
|
||||
rubocop (1.74.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.36.2, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.38.0)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-minitest (0.36.0)
|
||||
rubocop (>= 1.61, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-performance (1.23.1)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails (2.29.1)
|
||||
rubocop-ast (1.41.0)
|
||||
parser (>= 3.3.7.2)
|
||||
rubocop-performance (1.24.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.72.1, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-rails (2.30.3)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.52.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails-omakase (1.0.0)
|
||||
rubocop
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.23.9)
|
||||
rubocop (>= 1.72.1, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-rails-omakase (1.1.0)
|
||||
rubocop (>= 1.72)
|
||||
rubocop-performance (>= 1.24)
|
||||
rubocop-rails (>= 2.30)
|
||||
ruby-lsp (0.23.12)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 1.2, < 2.0)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.4.0)
|
||||
ruby-lsp (>= 0.23.0, < 0.24.0)
|
||||
ruby-openai (8.1.0)
|
||||
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||
faraday (>= 1)
|
||||
faraday-multipart (>= 1)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.3)
|
||||
ffi (~> 1.12)
|
||||
@@ -450,18 +445,27 @@ GEM
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.29.1)
|
||||
selenium-webdriver (4.31.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.22.4)
|
||||
sentry-rails (5.23.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.22.4)
|
||||
sentry-ruby (5.22.4)
|
||||
sentry-ruby (~> 5.23.0)
|
||||
sentry-ruby (5.23.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sentry-sidekiq (5.23.0)
|
||||
sentry-ruby (~> 5.23.0)
|
||||
sidekiq (>= 3.0)
|
||||
sidekiq (8.0.2)
|
||||
connection_pool (>= 2.5.0)
|
||||
json (>= 2.9.0)
|
||||
logger (>= 1.6.2)
|
||||
rack (>= 3.1.0)
|
||||
redis-client (>= 0.23.2)
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
@@ -469,44 +473,44 @@ GEM
|
||||
simplecov-html (0.13.1)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11813)
|
||||
stackprof (0.2.27)
|
||||
sorbet-runtime (0.5.11953)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.3)
|
||||
stripe (13.4.1)
|
||||
tailwindcss-rails (4.0.0)
|
||||
stringio (3.1.5)
|
||||
stripe (14.0.0)
|
||||
tailwindcss-rails (4.2.1)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby (~> 4.0)
|
||||
tailwindcss-ruby (4.0.6)
|
||||
tailwindcss-ruby (4.0.6-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.0.6-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.0.6-arm64-darwin)
|
||||
tailwindcss-ruby (4.0.6-x86_64-darwin)
|
||||
tailwindcss-ruby (4.0.6-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.0.6-x86_64-linux-musl)
|
||||
tailwindcss-ruby (4.0.15)
|
||||
tailwindcss-ruby (4.0.15-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.0.15-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.0.15-arm64-darwin)
|
||||
tailwindcss-ruby (4.0.15-x86_64-darwin)
|
||||
tailwindcss-ruby (4.0.15-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.0.15-x86_64-linux-musl)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
thor (1.3.2)
|
||||
timeout (0.4.3)
|
||||
turbo-rails (2.0.11)
|
||||
actionpack (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
turbo-rails (2.0.13)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.2)
|
||||
uri (1.0.3)
|
||||
useragent (0.16.11)
|
||||
vcr (6.3.1)
|
||||
base64
|
||||
vernier (1.7.0)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webmock (3.25.0)
|
||||
webmock (3.25.1)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
@@ -517,22 +521,20 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.1)
|
||||
zeitwerk (2.7.2)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
aarch64-linux-gnu
|
||||
aarch64-linux-musl
|
||||
arm-linux
|
||||
arm-linux-gnu
|
||||
arm-linux-musl
|
||||
arm64-darwin
|
||||
x86_64-darwin
|
||||
x86_64-linux
|
||||
x86_64-linux-gnu
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
activerecord-import
|
||||
aws-sdk-s3 (~> 1.177.0)
|
||||
bcrypt (~> 3.1)
|
||||
benchmark-ips
|
||||
@@ -548,9 +550,8 @@ DEPENDENCIES
|
||||
faraday
|
||||
faraday-multipart
|
||||
faraday-retry
|
||||
good_job
|
||||
hotwire-livereload
|
||||
hotwire_combobox!
|
||||
hotwire_combobox
|
||||
i18n-tasks
|
||||
image_processing (>= 1.2)
|
||||
importmap-rails
|
||||
@@ -571,21 +572,25 @@ DEPENDENCIES
|
||||
rails (~> 7.2.2)
|
||||
rails-settings-cached
|
||||
redcarpet
|
||||
redis (~> 5.4)
|
||||
rotp (~> 6.3)
|
||||
rqrcode (~> 2.2)
|
||||
rubocop-rails-omakase
|
||||
ruby-lsp-rails
|
||||
ruby-openai
|
||||
selenium-webdriver
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
sentry-sidekiq
|
||||
sidekiq
|
||||
simplecov
|
||||
stackprof
|
||||
stimulus-rails
|
||||
stripe
|
||||
tailwindcss-rails
|
||||
turbo-rails
|
||||
tzinfo-data
|
||||
vcr
|
||||
vernier
|
||||
web-console
|
||||
webmock
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
|
||||
css: bundle exec bin/rails tailwindcss:watch
|
||||
worker: bundle exec good_job start
|
||||
worker: bundle exec sidekiq
|
||||
|
||||
43
README.md
43
README.md
@@ -6,9 +6,6 @@
|
||||
<b>Get
|
||||
involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybefinance.com) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
|
||||
|
||||
_If you're looking for the previous React codebase, you can find it
|
||||
at [maybe-finance/maybe-archive](https://github.com/maybe-finance/maybe-archive)._
|
||||
|
||||
## Backstory
|
||||
|
||||
We spent the better part of 2021/2022 building a personal finance + wealth
|
||||
@@ -29,9 +26,8 @@ and eventually offer a hosted version of the app for a small monthly fee.
|
||||
|
||||
There are 3 primary ways to use the Maybe app:
|
||||
|
||||
1. Managed (easiest) - _coming soon..._
|
||||
2. [One-click deploy](docs/hosting/one-click-deploy.md)
|
||||
3. [Self-host with Docker](docs/hosting/docker.md)
|
||||
1. Managed (easiest) - we're in alpha and release invites in our Discord
|
||||
2. [Self-host with Docker](docs/hosting/docker.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -84,37 +80,10 @@ If you'd like multi-currency support, there are a few extra steps to follow.
|
||||
|
||||
### Setup Guides
|
||||
|
||||
#### Dev Container (optional)
|
||||
|
||||
This is 100% optional and meant for devs who don't want to worry about
|
||||
installing requirements manually for their platform. You can
|
||||
follow [this guide](https://code.visualstudio.com/docs/devcontainers/containers)
|
||||
to learn more about Dev Containers.
|
||||
|
||||
If you run into `could not connect to server` errors, you may need to change
|
||||
your `.env`'s `DB_HOST` environment variable value to `db` to point to the
|
||||
Postgres container.
|
||||
|
||||
#### Mac
|
||||
|
||||
Please visit
|
||||
our [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide).
|
||||
|
||||
#### Linux
|
||||
|
||||
Please visit
|
||||
our [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide).
|
||||
|
||||
#### Windows
|
||||
|
||||
Please visit
|
||||
our [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide).
|
||||
|
||||
### Testing Emails
|
||||
|
||||
In development, we use `letter_opener` to automatically open emails in your
|
||||
browser. When an email sends locally, a new browser tab will open with a
|
||||
preview.
|
||||
- [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide)
|
||||
- [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide)
|
||||
- [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide)
|
||||
- Dev containers - visit [this guide](https://code.visualstudio.com/docs/devcontainers/containers) to learn more
|
||||
|
||||
## Repo Activity
|
||||
|
||||
|
||||
85
app/assets/images/ai.svg
Normal file
85
app/assets/images/ai.svg
Normal file
@@ -0,0 +1,85 @@
|
||||
<svg width="62" height="68" viewBox="0 0 62 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_f_7620_90382)">
|
||||
<path d="M15.0109 27.3668C14.8138 11.2848 17.2087 15.4884 28.5797 15.5133L32.8675 15.5228C44.2383 15.5478 46.7179 11.3549 46.9149 27.4368L46.9891 33.5015C47.1861 49.5834 44.7913 53.0249 33.4205 52.9999L29.1325 52.9906C17.7617 52.9656 15.2823 49.5134 15.0852 33.4315L15.0109 27.3668Z" fill="url(#paint0_linear_7620_90382)" fill-opacity="0.15"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_i_7620_90382)">
|
||||
<rect x="15" y="13" width="32" height="32" rx="10.6667" fill="url(#paint1_linear_7620_90382)"/>
|
||||
<rect x="15" y="13" width="32" height="32" rx="10.6667" fill="white" fill-opacity="0.07" style="mix-blend-mode:plus-lighter"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_ii_7620_90382)">
|
||||
<rect x="16.7773" y="14.7778" width="28.4444" height="28.4444" rx="8.88889" fill="url(#paint2_linear_7620_90382)"/>
|
||||
<path d="M36.1921 22.073C36.6039 22.0652 36.9439 22.3927 36.9517 22.8044C36.9786 24.2352 37.0273 25.6596 37.0958 27.088C37.1155 27.4993 36.7981 27.8487 36.3868 27.8684C35.9755 27.8881 35.6261 27.5707 35.6063 27.1594C35.5372 25.7174 35.488 24.2785 35.4607 22.8325C35.453 22.4208 35.7804 22.0807 36.1921 22.073Z" fill="#141414"/>
|
||||
<path d="M36.1921 22.073C36.6039 22.0652 36.9439 22.3927 36.9517 22.8044C36.9786 24.2352 37.0273 25.6596 37.0958 27.088C37.1155 27.4993 36.7981 27.8487 36.3868 27.8684C35.9755 27.8881 35.6261 27.5707 35.6063 27.1594C35.5372 25.7174 35.488 24.2785 35.4607 22.8325C35.453 22.4208 35.7804 22.0807 36.1921 22.073Z" fill="url(#paint3_linear_7620_90382)"/>
|
||||
<path d="M30.0884 22.7413C30.3247 22.4041 30.2428 21.9392 29.9056 21.7029C29.5684 21.4666 29.1034 21.5484 28.8671 21.8857C28.2555 22.7586 27.6031 23.6183 26.9349 24.4988C26.6795 24.8354 26.4217 25.1751 26.1631 25.5196C26.1629 25.4497 26.1627 25.379 26.1622 25.3074C26.158 24.7 26.1364 24.0511 26.0192 23.3998C25.9463 22.9946 25.5586 22.7251 25.1533 22.7981C24.7481 22.871 24.4787 23.2587 24.5516 23.6639C24.6454 24.185 24.667 24.7297 24.671 25.3176C24.672 25.4628 24.6719 25.6127 24.6718 25.7658C24.6714 26.2118 24.6709 26.6845 24.6985 27.1431C24.7046 27.2459 24.7313 27.3426 24.7744 27.4294C24.0609 28.4557 23.3903 29.5154 22.8297 30.615C22.8221 30.6299 22.8109 30.6511 22.7968 30.6775C22.7112 30.8389 22.5209 31.1974 22.4089 31.5427C22.3452 31.7392 22.2741 32.0229 22.3076 32.3158C22.3255 32.4722 22.3762 32.6577 22.4979 32.8323C22.6242 33.0135 22.7989 33.142 22.9963 33.2166C24.2085 33.6749 25.5494 33.7216 26.818 33.625C27.848 33.5466 28.8878 33.3675 29.8142 33.2078C30.0261 33.1713 30.2321 33.1358 30.4306 33.1028C30.8368 33.0352 31.1113 32.6512 31.0438 32.245C30.9762 31.8388 30.5921 31.5643 30.1859 31.6318C29.9702 31.6677 29.7524 31.7052 29.5328 31.743C28.6101 31.9017 27.6572 32.0656 26.7048 32.1381C25.6662 32.2172 24.6958 32.1801 23.852 31.9317C23.9193 31.7487 24.0152 31.566 24.0967 31.4107C24.1185 31.3691 24.1393 31.3294 24.1582 31.2923C24.909 29.8195 25.8865 28.397 26.9361 26.9776C27.3129 26.4681 27.7032 25.9536 28.0947 25.4375C28.7774 24.5377 29.4637 23.6329 30.0884 22.7413Z" fill="#141414"/>
|
||||
<path d="M30.0884 22.7413C30.3247 22.4041 30.2428 21.9392 29.9056 21.7029C29.5684 21.4666 29.1034 21.5484 28.8671 21.8857C28.2555 22.7586 27.6031 23.6183 26.9349 24.4988C26.6795 24.8354 26.4217 25.1751 26.1631 25.5196C26.1629 25.4497 26.1627 25.379 26.1622 25.3074C26.158 24.7 26.1364 24.0511 26.0192 23.3998C25.9463 22.9946 25.5586 22.7251 25.1533 22.7981C24.7481 22.871 24.4787 23.2587 24.5516 23.6639C24.6454 24.185 24.667 24.7297 24.671 25.3176C24.672 25.4628 24.6719 25.6127 24.6718 25.7658C24.6714 26.2118 24.6709 26.6845 24.6985 27.1431C24.7046 27.2459 24.7313 27.3426 24.7744 27.4294C24.0609 28.4557 23.3903 29.5154 22.8297 30.615C22.8221 30.6299 22.8109 30.6511 22.7968 30.6775C22.7112 30.8389 22.5209 31.1974 22.4089 31.5427C22.3452 31.7392 22.2741 32.0229 22.3076 32.3158C22.3255 32.4722 22.3762 32.6577 22.4979 32.8323C22.6242 33.0135 22.7989 33.142 22.9963 33.2166C24.2085 33.6749 25.5494 33.7216 26.818 33.625C27.848 33.5466 28.8878 33.3675 29.8142 33.2078C30.0261 33.1713 30.2321 33.1358 30.4306 33.1028C30.8368 33.0352 31.1113 32.6512 31.0438 32.245C30.9762 31.8388 30.5921 31.5643 30.1859 31.6318C29.9702 31.6677 29.7524 31.7052 29.5328 31.743C28.6101 31.9017 27.6572 32.0656 26.7048 32.1381C25.6662 32.2172 24.6958 32.1801 23.852 31.9317C23.9193 31.7487 24.0152 31.566 24.0967 31.4107C24.1185 31.3691 24.1393 31.3294 24.1582 31.2923C24.909 29.8195 25.8865 28.397 26.9361 26.9776C27.3129 26.4681 27.7032 25.9536 28.0947 25.4375C28.7774 24.5377 29.4637 23.6329 30.0884 22.7413Z" fill="url(#paint4_linear_7620_90382)"/>
|
||||
<path d="M36.2391 34.7581C36.3664 34.3664 36.1522 33.9458 35.7606 33.8185C35.369 33.6911 34.9483 33.9054 34.821 34.297C34.6438 34.842 34.4106 35.256 34.12 35.541C33.8419 35.8137 33.4787 36.0015 32.9619 36.0499C32.1922 36.1221 31.4116 35.7978 31.071 35.2344C30.858 34.882 30.3996 34.7691 30.0472 34.9821C29.6948 35.1951 29.5818 35.6535 29.7949 36.0059C30.5079 37.1855 31.9259 37.6448 33.1011 37.5346C33.9477 37.4552 34.6338 37.1258 35.1641 36.6056C35.6819 36.0978 36.0163 35.4433 36.2391 34.7581Z" fill="#141414"/>
|
||||
<path d="M36.2391 34.7581C36.3664 34.3664 36.1522 33.9458 35.7606 33.8185C35.369 33.6911 34.9483 33.9054 34.821 34.297C34.6438 34.842 34.4106 35.256 34.12 35.541C33.8419 35.8137 33.4787 36.0015 32.9619 36.0499C32.1922 36.1221 31.4116 35.7978 31.071 35.2344C30.858 34.882 30.3996 34.7691 30.0472 34.9821C29.6948 35.1951 29.5818 35.6535 29.7949 36.0059C30.5079 37.1855 31.9259 37.6448 33.1011 37.5346C33.9477 37.4552 34.6338 37.1258 35.1641 36.6056C35.6819 36.0978 36.0163 35.4433 36.2391 34.7581Z" fill="url(#paint5_linear_7620_90382)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_7620_90382" x="0.937778" y="0.937778" width="60.1244" height="66.1244" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="7.03111" result="effect1_foregroundBlur_7620_90382"/>
|
||||
</filter>
|
||||
<filter id="filter1_i_7620_90382" x="15" y="13" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.49869"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_7620_90382"/>
|
||||
</filter>
|
||||
<filter id="filter2_ii_7620_90382" x="16.7773" y="13.8889" width="28.4453" height="30.2222" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.888889"/>
|
||||
<feGaussianBlur stdDeviation="0.888889"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.980392 0 0 0 0 0.309804 0 0 0 0 0.67451 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_7620_90382"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.888889"/>
|
||||
<feGaussianBlur stdDeviation="0.888889"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.835294 0 0 0 0 1 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="effect1_innerShadow_7620_90382" result="effect2_innerShadow_7620_90382"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_7620_90382" x1="30.1185" y1="16.1417" x2="33.7041" y2="53.1754" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_7620_90382" x1="31" y1="13" x2="31" y2="45" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_7620_90382" x1="30.9996" y1="23.6667" x2="30.9996" y2="43.2222" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.3" stop-color="#F7F7F7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_7620_90382" x1="32.5419" y1="21.0645" x2="28.4008" y2="36.5193" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_7620_90382" x1="32.5419" y1="21.0645" x2="28.4008" y2="36.5193" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_7620_90382" x1="32.5419" y1="21.0645" x2="28.4008" y2="36.5193" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.5 KiB |
@@ -1 +0,0 @@
|
||||
/* Application styles */
|
||||
@@ -8,6 +8,35 @@
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
@import "./simonweb_pickr.css";
|
||||
|
||||
@layer components {
|
||||
.pcr-app{
|
||||
position: static !important;
|
||||
background: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.pcr-color-palette{
|
||||
height: 12em !important;
|
||||
width: 21.5rem !important;
|
||||
}
|
||||
.pcr-palette{
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
.pcr-palette:before{
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
.pcr-color-chooser{
|
||||
height: 1.5em !important;
|
||||
}
|
||||
.pcr-picker{
|
||||
height: 20px !important;
|
||||
width: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.combobox {
|
||||
.hw-combobox__main__wrapper,
|
||||
.hw-combobox__input {
|
||||
@@ -83,6 +112,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
.prose--ai-chat {
|
||||
@apply break-words;
|
||||
|
||||
p, li {
|
||||
@apply text-sm text-primary;
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar implementation for Windows browsers */
|
||||
.windows {
|
||||
::-webkit-scrollbar {
|
||||
@@ -112,4 +165,5 @@
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* The following Markdown CSS has been removed as requested */
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
One-off styling (3rd party overrides, etc.) should be done in the application.css file.
|
||||
*/
|
||||
|
||||
@custom-variant theme-dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||
|
||||
@theme {
|
||||
/* Font families */
|
||||
--font-sans: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
@@ -241,83 +243,240 @@
|
||||
/* Design system color utilities */
|
||||
@utility text-primary {
|
||||
@apply text-gray-900;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-white;
|
||||
}
|
||||
}
|
||||
|
||||
@utility text-secondary {
|
||||
@apply text-gray-500;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
@utility text-subdued {
|
||||
@apply text-gray-400;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
@utility text-link {
|
||||
@apply text-blue-600;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-surface {
|
||||
@apply bg-gray-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-black;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-surface-hover {
|
||||
@apply bg-gray-100;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-surface-inset {
|
||||
@apply bg-gray-100;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-surface-inset-hover {
|
||||
@apply bg-gray-200;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-container {
|
||||
@apply bg-white;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-container-hover {
|
||||
@apply bg-gray-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-container-inset {
|
||||
@apply bg-gray-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-container-inset-hover {
|
||||
@apply bg-gray-100;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-inverse {
|
||||
@apply bg-gray-800;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-white;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-inverse-hover {
|
||||
@apply bg-gray-700;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-overlay {
|
||||
@apply bg-alpha-black-200;
|
||||
background-color: rgba(var(--color-gray-100), 0.5);
|
||||
|
||||
@variant theme-dark {
|
||||
background-color: var(--color-alpha-black-900);
|
||||
}
|
||||
}
|
||||
|
||||
@utility border-primary {
|
||||
@apply border-alpha-black-300;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply border-alpha-white-400;
|
||||
}
|
||||
}
|
||||
|
||||
@utility border-secondary {
|
||||
@apply border-alpha-black-200;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply border-alpha-white-300;
|
||||
}
|
||||
}
|
||||
|
||||
@utility border-tertiary {
|
||||
@apply border-alpha-black-100;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply border-alpha-white-200;
|
||||
}
|
||||
}
|
||||
|
||||
@utility border-subdued {
|
||||
@apply border-alpha-black-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply border-alpha-white-100;
|
||||
}
|
||||
}
|
||||
|
||||
@utility border-solid {
|
||||
@apply border-black;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply border-white;
|
||||
}
|
||||
}
|
||||
|
||||
@utility border-destructive {
|
||||
@apply border-red-500;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply border-red-400;
|
||||
}
|
||||
}
|
||||
|
||||
/* Foreground Colors */
|
||||
@utility fg-gray {
|
||||
@apply text-gray-500;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
@utility fg-contrast {
|
||||
@apply text-gray-400;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
}
|
||||
|
||||
@utility fg-inverse {
|
||||
@apply text-white;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
@utility fg-primary {
|
||||
@apply text-gray-900;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-white;
|
||||
}
|
||||
}
|
||||
|
||||
@utility fg-primary-variant {
|
||||
@apply text-gray-800;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
@utility fg-secondary {
|
||||
@apply text-gray-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
@utility fg-secondary-variant {
|
||||
@apply text-gray-100;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
@utility fg-subdued {
|
||||
@apply text-gray-400;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
form>button {
|
||||
@apply cursor-pointer;
|
||||
button {
|
||||
@apply cursor-pointer focus-visible:outline-gray-900;
|
||||
}
|
||||
|
||||
hr {
|
||||
@@ -331,22 +490,14 @@
|
||||
details>summary {
|
||||
@apply list-none;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] {
|
||||
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option {
|
||||
@apply py-2 rounded-md;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option:checked {
|
||||
@apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option:active,
|
||||
select[multiple="multiple"] option:focus {
|
||||
@apply bg-white;
|
||||
|
||||
input[type='radio'] {
|
||||
@apply border-gray-300 text-indigo-600 focus:ring-indigo-600; /* Default light mode */
|
||||
|
||||
@variant theme-dark {
|
||||
/* Dark mode radio button base and checked styles */
|
||||
@apply border-gray-600 bg-gray-700 checked:bg-blue-500 focus:ring-blue-500 focus:ring-offset-gray-800;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,44 +505,97 @@
|
||||
/* Buttons */
|
||||
.btn {
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500;
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
@apply bg-gray-900 text-white hover:bg-gray-700 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
|
||||
@apply button-bg-primary text-white disabled:text-gray-400;
|
||||
@apply hover:button-bg-primary-hover;
|
||||
@apply disabled:button-bg-disabled disabled:hover:button-bg-disabled;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply button-bg-primary fg-primary;
|
||||
@apply hover:button-bg-primary-hover;
|
||||
@apply disabled:button-bg-disabled disabled:hover:button-bg-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
@apply bg-gray-50 hover:bg-gray-100 text-gray-900;
|
||||
@apply button-bg-secondary text-primary;
|
||||
@apply hover:button-bg-secondary-hover;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply button-bg-secondary text-white;
|
||||
@apply hover:button-bg-secondary-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.btn--outline {
|
||||
@apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
|
||||
@apply border border-alpha-black-200 text-primary disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-gray-400;
|
||||
@apply hover:button-bg-outline-hover;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply border-alpha-white-300 text-white disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-gray-600;
|
||||
@apply hover:button-bg-outline-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
@apply border border-transparent text-gray-900 hover:bg-gray-100;
|
||||
@apply border border-transparent text-primary hover:button-bg-ghost-hover;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply fg-primary hover:button-bg-ghost-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.btn--destructive {
|
||||
@apply bg-red-500 text-white hover:bg-red-600 disabled:bg-red-50 disabled:hover:bg-red-50 disabled:text-red-400;
|
||||
@apply button-bg-destructive text-white hover:button-bg-destructive-hover disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-red-400;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply button-bg-destructive text-white hover:button-bg-destructive-hover disabled:button-bg-disabled disabled:hover:button-bg-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-field {
|
||||
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-white border-alpha-black-100 shadow-xs w-full;
|
||||
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
||||
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-container border-secondary shadow-xs w-full;
|
||||
@apply focus-within:border-secondary focus-within:shadow-none focus-within:ring-4 focus-within:ring-alpha-black-200;
|
||||
@apply transition-all duration-300;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply focus-within:ring-alpha-white-300;
|
||||
}
|
||||
|
||||
/* Add styles for multiple select within form fields */
|
||||
select[multiple] {
|
||||
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
|
||||
|
||||
option {
|
||||
@apply py-2 rounded-md;
|
||||
}
|
||||
|
||||
option:checked {
|
||||
@apply after:content-['\2713'] bg-container-inset after:text-gray-500 after:ml-2;
|
||||
}
|
||||
|
||||
option:active,
|
||||
option:focus {
|
||||
@apply bg-container-inset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
||||
@apply block text-xs text-secondary peer-disabled:text-subdued;
|
||||
}
|
||||
|
||||
.form-field__input {
|
||||
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
|
||||
@apply focus:opacity-100 focus:outline-hidden focus:ring-0;
|
||||
@apply placeholder-shown:opacity-50;
|
||||
@apply disabled:text-gray-400;
|
||||
@apply disabled:text-subdued;
|
||||
@apply text-ellipsis overflow-hidden whitespace-nowrap;
|
||||
@apply transition-opacity duration-300;
|
||||
|
||||
&select {
|
||||
@apply pr-8;
|
||||
@@ -399,17 +603,18 @@
|
||||
}
|
||||
|
||||
.form-field__radio {
|
||||
@apply text-gray-900;
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
.form-field__submit {
|
||||
@apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
||||
@apply cursor-pointer rounded-lg bg-surface p-3 text-center text-white hover:bg-surface-hover;
|
||||
}
|
||||
|
||||
/* Checkboxes */
|
||||
.checkbox {
|
||||
&[type='checkbox'] {
|
||||
@apply rounded-sm;
|
||||
@apply transition-colors duration-300;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,12 +645,119 @@
|
||||
/* Switches */
|
||||
.switch {
|
||||
@apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer;
|
||||
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out;
|
||||
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full;
|
||||
@apply after:transition-transform after:duration-300 after:ease-in-out;
|
||||
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
|
||||
@apply transition-colors duration-300;
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
.tooltip {
|
||||
@apply hidden absolute;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Specific override for strong tags in prose under dark mode */
|
||||
.prose:where([data-theme=dark], [data-theme=dark] *) strong {
|
||||
color: theme(colors.white) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Backgrounds */
|
||||
@utility button-bg-primary {
|
||||
@apply bg-gray-900; /* Maps to fg-primary light */
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-white; /* Maps to fg-primary dark */
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-primary-hover {
|
||||
@apply bg-gray-800; /* Maps to fg-primary-variant light */
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-50; /* Maps to fg-primary-variant dark */
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-secondary {
|
||||
@apply bg-gray-50; /* Maps to fg-secondary light */
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-700; /* Maps to fg-secondary dark */
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-secondary-hover {
|
||||
@apply bg-gray-100; /* Maps to fg-secondary-variant light */
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-600; /* Maps to fg-secondary-variant dark */
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-disabled {
|
||||
@apply bg-gray-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-destructive {
|
||||
@apply bg-red-500;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-red-400;
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-destructive-hover {
|
||||
@apply bg-red-600;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-red-500;
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-ghost-hover {
|
||||
@apply bg-gray-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
@utility button-bg-outline-hover {
|
||||
@apply bg-gray-100;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tab Styles */
|
||||
@utility tab-item-active {
|
||||
@apply bg-white;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
@utility tab-item-hover {
|
||||
@apply bg-gray-200;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
@utility tab-bg-group {
|
||||
@apply bg-gray-50;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-alpha-black-700;
|
||||
}
|
||||
}
|
||||
2
app/assets/tailwind/simonweb_pickr.css
Normal file
2
app/assets/tailwind/simonweb_pickr.css
Normal file
File diff suppressed because one or more lines are too long
@@ -1,4 +1,10 @@
|
||||
module ApplicationCable
|
||||
class Connection < ActionCable::Connection::Base
|
||||
rescue_from StandardError, with: :report_error
|
||||
|
||||
private
|
||||
def report_error(e)
|
||||
Sentry.capture_exception(e)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,9 +9,12 @@ class Account::HoldingsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@holding.destroy_holding_and_entries!
|
||||
|
||||
flash[:notice] = t(".success")
|
||||
if @holding.account.plaid_account_id.present?
|
||||
flash[:alert] = "You cannot delete this holding"
|
||||
else
|
||||
@holding.destroy_holding_and_entries!
|
||||
flash[:notice] = t(".success")
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@holding.account) }
|
||||
|
||||
@@ -10,7 +10,7 @@ class Account::TradesController < ApplicationController
|
||||
|
||||
def create_entry_params
|
||||
params.require(:account_entry).permit(
|
||||
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :type, :transfer_account_id
|
||||
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
).tap do |params|
|
||||
account_id = params.delete(:account_id)
|
||||
params[:account] = Current.family.accounts.find(account_id)
|
||||
|
||||
@@ -3,7 +3,7 @@ class Account::TransferMatchesController < ApplicationController
|
||||
|
||||
def new
|
||||
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
|
||||
@transfer_match_candidates = @entry.transfer_match_candidates
|
||||
@transfer_match_candidates = @entry.account_transaction.transfer_match_candidates
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class AccountsController < ApplicationController
|
||||
before_action :set_account, only: %i[sync chart sparkline]
|
||||
include Periodable
|
||||
|
||||
def index
|
||||
@manual_accounts = family.accounts.manual.alphabetically
|
||||
@@ -17,6 +18,7 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def chart
|
||||
@chart_view = params[:chart_view] || "balance"
|
||||
render layout: "application"
|
||||
end
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable
|
||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable
|
||||
include Pagy::Backend
|
||||
|
||||
helper_method :require_upgrade?, :subscription_pending?
|
||||
|
||||
before_action :detect_os
|
||||
before_action :set_default_chat
|
||||
|
||||
private
|
||||
def require_upgrade?
|
||||
@@ -33,4 +34,10 @@ class ApplicationController < ActionController::Base
|
||||
else ""
|
||||
end
|
||||
end
|
||||
|
||||
# By default, we show the user the last chat they interacted with
|
||||
def set_default_chat
|
||||
@last_viewed_chat = Current.user&.last_viewed_chat
|
||||
@chat = @last_viewed_chat
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,6 +25,7 @@ class BudgetsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def budget_create_params
|
||||
params.require(:budget).permit(:start_date)
|
||||
end
|
||||
|
||||
67
app/controllers/chats_controller.rb
Normal file
67
app/controllers/chats_controller.rb
Normal file
@@ -0,0 +1,67 @@
|
||||
class ChatsController < ApplicationController
|
||||
include ActionView::RecordIdentifier
|
||||
|
||||
guard_feature unless: -> { Current.user.ai_enabled? }
|
||||
|
||||
before_action :set_chat, only: [ :show, :edit, :update, :destroy ]
|
||||
|
||||
def index
|
||||
@chat = nil # override application_controller default behavior of setting @chat to last viewed chat
|
||||
@chats = Current.user.chats.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def show
|
||||
set_last_viewed_chat(@chat)
|
||||
end
|
||||
|
||||
def new
|
||||
@chat = Current.user.chats.new(title: "New chat #{Time.current.strftime("%Y-%m-%d %H:%M")}")
|
||||
end
|
||||
|
||||
def create
|
||||
@chat = Current.user.chats.start!(chat_params[:content], model: chat_params[:ai_model])
|
||||
set_last_viewed_chat(@chat)
|
||||
redirect_to chat_path(@chat, thinking: true)
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@chat.update!(chat_params)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to chat_path(@chat), notice: "Chat updated" }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@chat, :title), partial: "chats/chat_title", locals: { chat: @chat }) }
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@chat.destroy
|
||||
clear_last_viewed_chat
|
||||
|
||||
redirect_to chats_path, notice: "Chat was successfully deleted"
|
||||
end
|
||||
|
||||
def retry
|
||||
@chat.retry_last_message!
|
||||
redirect_to chat_path(@chat, thinking: true)
|
||||
end
|
||||
|
||||
private
|
||||
def set_chat
|
||||
@chat = Current.user.chats.find(params[:id])
|
||||
end
|
||||
|
||||
def set_last_viewed_chat(chat)
|
||||
Current.user.update!(last_viewed_chat: chat)
|
||||
end
|
||||
|
||||
def clear_last_viewed_chat
|
||||
Current.user.update!(last_viewed_chat: nil)
|
||||
end
|
||||
|
||||
def chat_params
|
||||
params.require(:chat).permit(:title, :content, :ai_model)
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@ module AccountableResource
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include ScrollFocusable
|
||||
include ScrollFocusable, Periodable
|
||||
|
||||
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
|
||||
before_action :set_link_token, only: :new
|
||||
@@ -23,6 +23,7 @@ module AccountableResource
|
||||
end
|
||||
|
||||
def show
|
||||
@chart_view = params[:chart_view] || "balance"
|
||||
@q = params.fetch(:q, {}).permit(:search)
|
||||
entries = @account.entries.search(@q).reverse_chronological
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ module Authentication
|
||||
included do
|
||||
before_action :set_request_details
|
||||
before_action :authenticate_user!
|
||||
before_action :set_sentry_user
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def skip_authentication(**options)
|
||||
skip_before_action :authenticate_user!, **options
|
||||
skip_before_action :set_sentry_user, **options
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,7 +28,13 @@ module Authentication
|
||||
end
|
||||
|
||||
def find_session_by_cookie
|
||||
Session.find_by(id: cookies.signed[:session_token])
|
||||
cookie_value = cookies.signed[:session_token]
|
||||
|
||||
if cookie_value.present?
|
||||
Session.find_by(id: cookie_value)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def create_session_for(user)
|
||||
@@ -43,4 +51,17 @@ module Authentication
|
||||
Current.user_agent = request.user_agent
|
||||
Current.ip_address = request.ip
|
||||
end
|
||||
|
||||
def set_sentry_user
|
||||
return unless defined?(Sentry) && ENV["SENTRY_DSN"].present?
|
||||
|
||||
if Current.user
|
||||
Sentry.set_user(
|
||||
id: Current.user.id,
|
||||
email: Current.user.email,
|
||||
username: Current.user.display_name,
|
||||
ip_address: Current.ip_address
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,6 +13,7 @@ module AutoSync
|
||||
|
||||
def family_needs_auto_sync?
|
||||
return false unless Current.family.present?
|
||||
return false unless Current.family.accounts.active.any?
|
||||
|
||||
(Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current
|
||||
end
|
||||
|
||||
13
app/controllers/concerns/breadcrumbable.rb
Normal file
13
app/controllers/concerns/breadcrumbable.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Breadcrumbable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_breadcrumbs
|
||||
end
|
||||
|
||||
private
|
||||
# The default, unless specific controller or action explicitly overrides
|
||||
def set_breadcrumbs
|
||||
@breadcrumbs = [ [ "Home", root_path ], [ controller_name.titleize, nil ] ]
|
||||
end
|
||||
end
|
||||
23
app/controllers/concerns/feature_guardable.rb
Normal file
23
app/controllers/concerns/feature_guardable.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
# Simple feature guard that renders a 403 Forbidden status with a message
|
||||
# when the feature is disabled.
|
||||
#
|
||||
# Example:
|
||||
#
|
||||
# class MessagesController < ApplicationController
|
||||
# guard_feature unless: -> { Current.user.ai_enabled? }
|
||||
# end
|
||||
#
|
||||
module FeatureGuardable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def guard_feature(**options)
|
||||
before_action :guard_feature, **options
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def guard_feature
|
||||
render plain: "Feature disabled: #{controller_name}##{action_name}", status: :forbidden
|
||||
end
|
||||
end
|
||||
14
app/controllers/concerns/periodable.rb
Normal file
14
app/controllers/concerns/periodable.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
module Periodable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_period
|
||||
end
|
||||
|
||||
private
|
||||
def set_period
|
||||
@period = Period.from_key(params[:period] || Current.user&.default_period)
|
||||
rescue Period::InvalidKeyError
|
||||
@period = Period.last_30_days
|
||||
end
|
||||
end
|
||||
@@ -35,6 +35,7 @@ class Import::ConfigurationsController < ApplicationController
|
||||
:notes_col_label,
|
||||
:currency_col_label,
|
||||
:date_format,
|
||||
:number_format,
|
||||
:signage_convention
|
||||
)
|
||||
end
|
||||
|
||||
@@ -4,6 +4,10 @@ class Import::ConfirmsController < ApplicationController
|
||||
before_action :set_import
|
||||
|
||||
def show
|
||||
if @import.mapping_steps.empty?
|
||||
return redirect_to import_path(@import)
|
||||
end
|
||||
|
||||
redirect_to import_clean_path(@import), alert: "You have invalid data, please edit until all errors are resolved" unless @import.cleaned?
|
||||
end
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@ class Import::RowsController < ApplicationController
|
||||
before_action :set_import_row
|
||||
|
||||
def update
|
||||
@row.assign_attributes(row_params)
|
||||
@row.save!(validate: false)
|
||||
@row.sync_mappings
|
||||
@row.update_and_sync(row_params)
|
||||
|
||||
redirect_to import_row_path(@row.import, @row)
|
||||
end
|
||||
|
||||
@@ -8,10 +8,11 @@ class Import::UploadsController < ApplicationController
|
||||
|
||||
def update
|
||||
if csv_valid?(csv_str)
|
||||
@import.account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))
|
||||
@import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep])
|
||||
@import.save!(validate: false)
|
||||
|
||||
redirect_to import_configuration_path(@import), notice: "CSV uploaded successfully."
|
||||
redirect_to import_configuration_path(@import, template_hint: true), notice: "CSV uploaded successfully."
|
||||
else
|
||||
flash.now[:alert] = "Must be valid CSV with headers and at least one row of data"
|
||||
|
||||
@@ -29,10 +30,8 @@ class Import::UploadsController < ApplicationController
|
||||
end
|
||||
|
||||
def csv_valid?(str)
|
||||
require "csv"
|
||||
|
||||
begin
|
||||
csv = CSV.parse(str || "", headers: true, col_sep: upload_params[:col_sep])
|
||||
csv = Import.parse_csv_str(str, col_sep: upload_params[:col_sep])
|
||||
return false if csv.headers.empty?
|
||||
return false if csv.count == 0
|
||||
true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class ImportsController < ApplicationController
|
||||
before_action :set_import, only: %i[show publish destroy revert]
|
||||
before_action :set_import, only: %i[show publish destroy revert apply_template]
|
||||
|
||||
def publish
|
||||
@import.publish_later
|
||||
@@ -18,7 +18,12 @@ class ImportsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
import = Current.family.imports.create! import_params
|
||||
account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))
|
||||
import = Current.family.imports.create!(
|
||||
type: import_params[:type],
|
||||
account: account,
|
||||
date_format: Current.family.date_format,
|
||||
)
|
||||
|
||||
redirect_to import_upload_path(import)
|
||||
end
|
||||
@@ -36,6 +41,15 @@ class ImportsController < ApplicationController
|
||||
redirect_to imports_path, notice: "Import is reverting in the background."
|
||||
end
|
||||
|
||||
def apply_template
|
||||
if @import.suggested_template
|
||||
@import.apply_template!(@import.suggested_template)
|
||||
redirect_to import_configuration_path(@import), notice: "Template applied."
|
||||
else
|
||||
redirect_to import_configuration_path(@import), alert: "No template found, please manually configure your import."
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@import.destroy
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
class Issue::ExchangeRateProviderMissingsController < ApplicationController
|
||||
before_action :set_issue, only: :update
|
||||
|
||||
def update
|
||||
Setting.synth_api_key = exchange_rate_params[:synth_api_key]
|
||||
account = @issue.issuable
|
||||
account.sync_later
|
||||
redirect_back_or_to account
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_issue
|
||||
@issue = Current.family.issues.find(params[:id])
|
||||
end
|
||||
|
||||
def exchange_rate_params
|
||||
params.require(:issue_exchange_rate_provider_missing).permit(:synth_api_key)
|
||||
end
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
class IssuesController < ApplicationController
|
||||
before_action :set_issue, only: :show
|
||||
|
||||
def show
|
||||
render template: "#{@issue.class.name.underscore.pluralize}/show", layout: "issues"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_issue
|
||||
@issue = Current.family.issues.find(params[:id])
|
||||
end
|
||||
end
|
||||
24
app/controllers/messages_controller.rb
Normal file
24
app/controllers/messages_controller.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
class MessagesController < ApplicationController
|
||||
guard_feature unless: -> { Current.user.ai_enabled? }
|
||||
|
||||
before_action :set_chat
|
||||
|
||||
def create
|
||||
@message = UserMessage.create!(
|
||||
chat: @chat,
|
||||
content: message_params[:content],
|
||||
ai_model: message_params[:ai_model]
|
||||
)
|
||||
|
||||
redirect_to chat_path(@chat, thinking: true)
|
||||
end
|
||||
|
||||
private
|
||||
def set_chat
|
||||
@chat = Current.user.chats.find(params[:chat_id])
|
||||
end
|
||||
|
||||
def message_params
|
||||
params.require(:message).permit(:content, :ai_model)
|
||||
end
|
||||
end
|
||||
@@ -20,7 +20,10 @@ class MfaController < ApplicationController
|
||||
|
||||
def verify
|
||||
@user = User.find_by(id: session[:mfa_user_id])
|
||||
redirect_to new_session_path unless @user
|
||||
|
||||
if @user.nil?
|
||||
redirect_to new_session_path
|
||||
end
|
||||
end
|
||||
|
||||
def verify_code
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
class PagesController < ApplicationController
|
||||
skip_before_action :authenticate_user!, only: %i[early_access]
|
||||
include Periodable
|
||||
|
||||
def dashboard
|
||||
@period = Period.from_key(params[:period], fallback: true)
|
||||
@balance_sheet = Current.family.balance_sheet
|
||||
@accounts = Current.family.accounts.active.with_attached_logo
|
||||
|
||||
@breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ]
|
||||
end
|
||||
|
||||
def changelog
|
||||
@release_notes = Provider::Github.new.fetch_latest_release_notes
|
||||
@release_notes = github_provider.fetch_latest_release_notes
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
@@ -24,4 +26,9 @@ class PagesController < ApplicationController
|
||||
@invite_code = InviteCode.order("RANDOM()").limit(1).first
|
||||
render layout: false
|
||||
end
|
||||
|
||||
private
|
||||
def github_provider
|
||||
Provider::Registry.get_provider(:github)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,7 +22,7 @@ class PlaidItemsController < ApplicationController
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to accounts_path }
|
||||
format.html { redirect_back_or_to accounts_path }
|
||||
format.json { head :ok }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
class SecuritiesController < ApplicationController
|
||||
def index
|
||||
query = params[:q]
|
||||
return render json: [] if query.blank? || query.length < 2 || query.length > 100
|
||||
|
||||
@securities = Security.search({
|
||||
search: query,
|
||||
country: params[:country_code] == "US" ? "US" : nil
|
||||
})
|
||||
@securities = Security.search_provider(
|
||||
params[:q],
|
||||
country_code: params[:country_code] == "US" ? "US" : nil
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
class Settings::HostingsController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
before_action :raise_if_not_self_hosted
|
||||
guard_feature unless: -> { self_hosted? }
|
||||
|
||||
before_action :ensure_admin, only: :clear_cache
|
||||
|
||||
def show
|
||||
@synth_usage = Current.family.synth_usage
|
||||
synth_provider = Provider::Registry.get_provider(:synth)
|
||||
@synth_usage = synth_provider&.usage
|
||||
end
|
||||
|
||||
def update
|
||||
if hosting_params[:upgrades_setting].present?
|
||||
mode = hosting_params[:upgrades_setting] == "manual" ? "manual" : "auto"
|
||||
target = hosting_params[:upgrades_setting] == "commit" ? "commit" : "release"
|
||||
|
||||
Setting.upgrades_mode = mode
|
||||
Setting.upgrades_target = target
|
||||
end
|
||||
|
||||
if hosting_params.key?(:render_deploy_hook)
|
||||
Setting.render_deploy_hook = hosting_params[:render_deploy_hook]
|
||||
end
|
||||
|
||||
if hosting_params.key?(:require_invite_for_signup)
|
||||
Setting.require_invite_for_signup = hosting_params[:require_invite_for_signup]
|
||||
end
|
||||
@@ -38,12 +29,17 @@ class Settings::HostingsController < ApplicationController
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def clear_cache
|
||||
DataCacheClearJob.perform_later(Current.family)
|
||||
redirect_to settings_hosting_path, notice: t(".cache_cleared")
|
||||
end
|
||||
|
||||
private
|
||||
def hosting_params
|
||||
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key)
|
||||
params.require(:setting).permit(:require_invite_for_signup, :require_email_confirmation, :synth_api_key)
|
||||
end
|
||||
|
||||
def raise_if_not_self_hosted
|
||||
raise "Settings not available on non-self-hosted instance" unless self_hosted?
|
||||
def ensure_admin
|
||||
redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class SubscriptionsController < ApplicationController
|
||||
before_action :redirect_to_root_if_self_hosted
|
||||
|
||||
def new
|
||||
if Current.family.stripe_customer_id.blank?
|
||||
customer = stripe_client.v1.customers.create(
|
||||
@@ -44,4 +46,8 @@ class SubscriptionsController < ApplicationController
|
||||
def stripe_client
|
||||
@stripe_client ||= Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
|
||||
end
|
||||
|
||||
def redirect_to_root_if_self_hosted
|
||||
redirect_to root_path, alert: I18n.t("subscriptions.self_hosted_alert") if self_hosted?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,6 +49,7 @@ class TransactionsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_params
|
||||
cleaned_params = params.fetch(:q, {})
|
||||
.permit(
|
||||
|
||||
@@ -36,14 +36,11 @@ class TransfersController < ApplicationController
|
||||
end
|
||||
|
||||
def update
|
||||
if transfer_update_params[:status] == "rejected"
|
||||
@transfer.reject!
|
||||
elsif transfer_update_params[:status] == "confirmed"
|
||||
@transfer.confirm!
|
||||
Transfer.transaction do
|
||||
update_transfer_status
|
||||
update_transfer_details
|
||||
end
|
||||
|
||||
@transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id])
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transactions_url, notice: t(".success") }
|
||||
format.turbo_stream
|
||||
@@ -69,4 +66,17 @@ class TransfersController < ApplicationController
|
||||
def transfer_update_params
|
||||
params.require(:transfer).permit(:notes, :status, :category_id)
|
||||
end
|
||||
|
||||
def update_transfer_status
|
||||
if transfer_update_params[:status] == "rejected"
|
||||
@transfer.reject!
|
||||
elsif transfer_update_params[:status] == "confirmed"
|
||||
@transfer.confirm!
|
||||
end
|
||||
end
|
||||
|
||||
def update_transfer_details
|
||||
@transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id])
|
||||
@transfer.update!(notes: transfer_update_params[:notes])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
class UpgradesController < ApplicationController
|
||||
before_action :verify_upgrades_enabled
|
||||
|
||||
def acknowledge
|
||||
commit_sha = params[:id]
|
||||
upgrade = Upgrader.find_upgrade(commit_sha)
|
||||
|
||||
if upgrade
|
||||
if upgrade.available?
|
||||
Current.user.acknowledge_upgrade_prompt(upgrade.commit_sha)
|
||||
flash[:notice] = t(".upgrade_dismissed")
|
||||
elsif upgrade.complete?
|
||||
Current.user.acknowledge_upgrade_alert(upgrade.commit_sha)
|
||||
flash[:notice] = t(".upgrade_complete_dismiss")
|
||||
else
|
||||
flash[:alert] = t(".upgrade_not_available")
|
||||
end
|
||||
else
|
||||
flash[:alert] = t(".upgrade_not_found")
|
||||
end
|
||||
|
||||
redirect_back(fallback_location: root_path)
|
||||
end
|
||||
|
||||
def deploy
|
||||
commit_sha = params[:id]
|
||||
upgrade = Upgrader.find_upgrade(commit_sha)
|
||||
|
||||
unless upgrade
|
||||
flash[:alert] = t(".upgrade_not_found")
|
||||
return redirect_back(fallback_location: root_path)
|
||||
end
|
||||
|
||||
prior_acknowledged_upgrade_commit_sha = Current.user.last_prompted_upgrade_commit_sha
|
||||
|
||||
# Optimistically acknowledge the upgrade prompt
|
||||
Current.user.acknowledge_upgrade_prompt(upgrade.commit_sha)
|
||||
|
||||
upgrade_result = Upgrader.upgrade_to(upgrade)
|
||||
|
||||
if upgrade_result[:success]
|
||||
flash[:notice] = upgrade_result[:message]
|
||||
else
|
||||
# If the upgrade fails, revert to the prior acknowledged upgrade
|
||||
Current.user.acknowledge_upgrade_prompt(prior_acknowledged_upgrade_commit_sha)
|
||||
flash[:alert] = upgrade_result[:message]
|
||||
end
|
||||
|
||||
redirect_back(fallback_location: root_path)
|
||||
end
|
||||
|
||||
private
|
||||
def verify_upgrades_enabled
|
||||
head :not_found unless ENV["UPGRADES_ENABLED"] == "true"
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,6 @@
|
||||
class UsersController < ApplicationController
|
||||
before_action :set_user
|
||||
before_action :ensure_admin, only: :reset
|
||||
|
||||
def update
|
||||
@user = Current.user
|
||||
@@ -16,16 +17,29 @@ class UsersController < ApplicationController
|
||||
redirect_to settings_profile_path, alert: error_message
|
||||
end
|
||||
else
|
||||
was_ai_enabled = @user.ai_enabled
|
||||
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
|
||||
@user.profile_image.purge if should_purge_profile_image?
|
||||
|
||||
# Add a special notice if AI was just enabled
|
||||
notice = if !was_ai_enabled && @user.ai_enabled
|
||||
"AI Assistant has been enabled successfully."
|
||||
else
|
||||
t(".success")
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html { handle_redirect(t(".success")) }
|
||||
format.html { handle_redirect(notice) }
|
||||
format.json { head :ok }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def reset
|
||||
FamilyResetJob.perform_later(Current.family)
|
||||
redirect_to settings_profile_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @user.deactivate
|
||||
Current.session.destroy
|
||||
@@ -60,7 +74,7 @@ class UsersController < ApplicationController
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(
|
||||
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar,
|
||||
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, :show_ai_sidebar, :ai_enabled, :theme,
|
||||
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ]
|
||||
)
|
||||
end
|
||||
@@ -68,4 +82,8 @@ class UsersController < ApplicationController
|
||||
def set_user
|
||||
@user = Current.user
|
||||
end
|
||||
|
||||
def ensure_admin
|
||||
redirect_to settings_profile_path, alert: I18n.t("users.reset.unauthorized") unless Current.user.admin?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
module Account::EntriesHelper
|
||||
def entries_by_date(entries, totals: false)
|
||||
entries.group_by(&:date).map do |date, grouped_entries|
|
||||
transfer_groups = entries.group_by do |entry|
|
||||
# Only check for transfer if it's a transaction
|
||||
next nil unless entry.entryable_type == "Account::Transaction"
|
||||
entry.entryable.transfer&.id
|
||||
end
|
||||
|
||||
# For a more intuitive UX, we do not want to show the same transfer twice in the list
|
||||
deduped_entries = transfer_groups.flat_map do |transfer_id, grouped_entries|
|
||||
if transfer_id.nil? || grouped_entries.size == 1
|
||||
grouped_entries
|
||||
else
|
||||
grouped_entries.reject do |e|
|
||||
e.entryable_type == "Account::Transaction" &&
|
||||
e.entryable.transfer_as_inflow.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
deduped_entries.group_by(&:date).sort.reverse_each.map do |date, grouped_entries|
|
||||
content = capture do
|
||||
yield grouped_entries
|
||||
end
|
||||
|
||||
@@ -129,6 +129,74 @@ module ApplicationHelper
|
||||
cookies[:admin] == "true"
|
||||
end
|
||||
|
||||
# Renders Markdown text using Redcarpet
|
||||
def markdown(text)
|
||||
return "" if text.blank?
|
||||
|
||||
renderer = Redcarpet::Render::HTML.new(
|
||||
hard_wrap: true,
|
||||
link_attributes: { target: "_blank", rel: "noopener noreferrer" }
|
||||
)
|
||||
|
||||
markdown = Redcarpet::Markdown.new(
|
||||
renderer,
|
||||
autolink: true,
|
||||
tables: true,
|
||||
fenced_code_blocks: true,
|
||||
strikethrough: true,
|
||||
superscript: true,
|
||||
underline: true,
|
||||
highlight: true,
|
||||
quote: true,
|
||||
footnotes: true
|
||||
)
|
||||
|
||||
markdown.render(text).html_safe
|
||||
end
|
||||
|
||||
# Determines the starting widths of each panel depending on the user's sidebar preferences
|
||||
def app_sidebar_config(user)
|
||||
left_sidebar_showing = user.show_sidebar?
|
||||
right_sidebar_showing = user.show_ai_sidebar?
|
||||
|
||||
content_max_width = if !left_sidebar_showing && !right_sidebar_showing
|
||||
1024 # 5xl
|
||||
elsif left_sidebar_showing && !right_sidebar_showing
|
||||
896 # 4xl
|
||||
else
|
||||
768 # 3xl
|
||||
end
|
||||
|
||||
left_panel_min_width = 320
|
||||
left_panel_max_width = 320
|
||||
right_panel_min_width = 400
|
||||
right_panel_max_width = 550
|
||||
|
||||
left_panel_width = left_sidebar_showing ? left_panel_min_width : 0
|
||||
right_panel_width = if right_sidebar_showing
|
||||
left_sidebar_showing ? right_panel_min_width : right_panel_max_width
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
{
|
||||
left_panel: {
|
||||
is_open: left_sidebar_showing,
|
||||
initial_width: left_panel_width,
|
||||
min_width: left_panel_min_width,
|
||||
max_width: left_panel_max_width
|
||||
},
|
||||
right_panel: {
|
||||
is_open: right_sidebar_showing,
|
||||
initial_width: right_panel_width,
|
||||
min_width: right_panel_min_width,
|
||||
max_width: right_panel_max_width,
|
||||
overflow: right_sidebar_showing ? "auto" : "hidden"
|
||||
},
|
||||
content_max_width: content_max_width
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def calculate_total(item, money_method, negate)
|
||||
items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? }
|
||||
|
||||
12
app/helpers/chats_helper.rb
Normal file
12
app/helpers/chats_helper.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module ChatsHelper
|
||||
def chat_frame
|
||||
:sidebar_chat
|
||||
end
|
||||
|
||||
def chat_view_path(chat)
|
||||
return new_chat_path if params[:chat_view] == "new"
|
||||
return chats_path if chat.nil? || params[:chat_view] == "all"
|
||||
|
||||
chat.persisted? ? chat_path(chat) : new_chat_path
|
||||
end
|
||||
end
|
||||
@@ -17,7 +17,7 @@ module FormsHelper
|
||||
end
|
||||
end
|
||||
|
||||
def period_select(form:, selected:, classes: "border border-tertiary shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0")
|
||||
def period_select(form:, selected:, classes: "border border-secondary bg-container-inset rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0")
|
||||
periods_for_select = Period.all.map { |period| [ period.label_short, period.key ] }
|
||||
|
||||
form.select(:period, periods_for_select, { selected: selected.key }, class: classes, data: { "auto-submit-form-target": "auto" })
|
||||
@@ -30,7 +30,7 @@ end
|
||||
|
||||
private
|
||||
def radio_tab_contents(label:, icon:)
|
||||
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-subdued group-has-checked:bg-white group-has-checked:text-gray-800 group-has-checked:shadow-sm") do
|
||||
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-subdued group-has-checked:bg-container group-has-checked:text-gray-800 group-has-checked:shadow-sm") do
|
||||
concat lucide_icon(icon, class: "w-5 h-5")
|
||||
concat tag.span(label, class: "group-has-checked:font-semibold")
|
||||
end
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
module MenusHelper
|
||||
def contextual_menu(&block)
|
||||
tag.div data: { controller: "menu" } do
|
||||
concat contextual_menu_icon
|
||||
def contextual_menu(icon: "more-horizontal", id: nil, &block)
|
||||
tag.div id: id, data: { controller: "menu" } do
|
||||
concat contextual_menu_icon(icon)
|
||||
concat contextual_menu_content(&block)
|
||||
end
|
||||
end
|
||||
|
||||
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal)
|
||||
link_to url, class: "flex items-center rounded-lg text-primary hover:bg-gray-50 py-2 px-3 gap-2", data: { turbo_frame: } do
|
||||
link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do
|
||||
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary"))
|
||||
concat(tag.span(label, class: "text-sm"))
|
||||
end
|
||||
end
|
||||
|
||||
def contextual_menu_item(label, url:, icon:, turbo_frame: nil)
|
||||
link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do
|
||||
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary"))
|
||||
concat(tag.span(label, class: "text-sm"))
|
||||
end
|
||||
@@ -16,7 +23,7 @@ module MenusHelper
|
||||
def contextual_menu_destructive_item(label, url, turbo_confirm: true, turbo_frame: nil)
|
||||
button_to url,
|
||||
method: :delete,
|
||||
class: "flex items-center w-full rounded-lg text-red-500 hover:bg-red-500/5 py-2 px-3 gap-2",
|
||||
class: "flex items-center w-full rounded-md text-red-500 hover:bg-red-500/5 p-2 gap-2",
|
||||
data: { turbo_confirm: turbo_confirm, turbo_frame: } do
|
||||
concat(lucide_icon("trash-2", class: "shrink-0 w-5 h-5"))
|
||||
concat(tag.span(label, class: "text-sm"))
|
||||
@@ -24,14 +31,14 @@ module MenusHelper
|
||||
end
|
||||
|
||||
private
|
||||
def contextual_menu_icon
|
||||
tag.button class: "flex hover:bg-gray-100 p-2 rounded cursor-pointer", data: { menu_target: "button" } do
|
||||
lucide_icon "more-horizontal", class: "w-5 h-5 text-secondary"
|
||||
def contextual_menu_icon(icon)
|
||||
tag.button class: "w-9 h-9 flex justify-center items-center hover:bg-surface-hover rounded-lg cursor-pointer focus:outline-none focus-visible:outline-none", data: { menu_target: "button" } do
|
||||
lucide_icon icon, class: "w-5 h-5 text-secondary"
|
||||
end
|
||||
end
|
||||
|
||||
def contextual_menu_content(&block)
|
||||
tag.div class: "z-50 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden",
|
||||
tag.div class: "min-w-[200px] p-1 z-50 shadow-border-xs bg-container rounded-lg hidden",
|
||||
data: { menu_target: "content" } do
|
||||
capture(&block)
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ module SettingsHelper
|
||||
{ name: I18n.t("settings.settings_nav.preferences_label"), path: :settings_preferences_path },
|
||||
{ name: I18n.t("settings.settings_nav.security_label"), path: :settings_security_path },
|
||||
{ name: I18n.t("settings.settings_nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
|
||||
{ name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path },
|
||||
{ name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path, condition: :not_self_hosted? },
|
||||
{ name: I18n.t("settings.settings_nav.accounts_label"), path: :accounts_path },
|
||||
{ name: I18n.t("settings.settings_nav.imports_label"), path: :imports_path },
|
||||
{ name: I18n.t("settings.settings_nav.tags_label"), path: :tags_path },
|
||||
@@ -45,4 +45,9 @@ module SettingsHelper
|
||||
concat(next_setting)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def not_self_hosted?
|
||||
!self_hosted?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
module UpgradesHelper
|
||||
def get_upgrade_for_notification(user, upgrades_mode)
|
||||
return nil unless ENV["UPGRADES_ENABLED"] == "true"
|
||||
|
||||
completed_upgrade = Upgrader.completed_upgrade
|
||||
return completed_upgrade if completed_upgrade && user.last_alerted_upgrade_commit_sha != completed_upgrade.commit_sha
|
||||
|
||||
available_upgrade = Upgrader.available_upgrade
|
||||
if available_upgrade && upgrades_mode == "manual" && user.last_prompted_upgrade_commit_sha != available_upgrade.commit_sha
|
||||
available_upgrade
|
||||
end
|
||||
end
|
||||
end
|
||||
206
app/javascript/controllers/category_controller.js
Normal file
206
app/javascript/controllers/category_controller.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import Pickr from '@simonwep/pickr'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["pickerBtn", "colorInput", "colorsSection", "paletteSection", "pickerSection", "colorPreview", "avatar", "details", "icon","validationMessage","selection","colorPickerRadioBtn"];
|
||||
static values = {
|
||||
presetColors: Array,
|
||||
};
|
||||
|
||||
initialize() {
|
||||
this.pickerBtnTarget.addEventListener('click', () => {
|
||||
this.showPaletteSection();
|
||||
});
|
||||
|
||||
this.colorInputTarget.addEventListener('input', (e) => {
|
||||
this.picker.setColor(e.target.value);
|
||||
});
|
||||
|
||||
this.detailsTarget.addEventListener('toggle', (e) => {
|
||||
if (!this.colorInputTarget.checkValidity()) {
|
||||
e.preventDefault();
|
||||
this.colorInputTarget.reportValidity();
|
||||
e.target.open = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.selectedIcon = null;
|
||||
|
||||
if (!this.presetColorsValue.includes(this.colorInputTarget.value)) {
|
||||
this.colorPickerRadioBtnTarget.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
initPicker() {
|
||||
const pickerContainer = document.createElement("div");
|
||||
pickerContainer.classList.add("pickerContainer");
|
||||
this.pickerSectionTarget.append(pickerContainer);
|
||||
|
||||
this.picker = Pickr.create({
|
||||
el: this.pickerBtnTarget,
|
||||
theme: 'monolith',
|
||||
container: ".pickerContainer",
|
||||
useAsButton: true,
|
||||
showAlways: true,
|
||||
default: this.colorInputTarget.value,
|
||||
components: {
|
||||
hue: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.picker.on('change', (color) => {
|
||||
const hexColor = color.toHEXA().toString();
|
||||
const rgbacolor = color.toRGBA();
|
||||
|
||||
this.updateAvatarColors(hexColor);
|
||||
this.updateSelectedIconColor(hexColor);
|
||||
|
||||
const backgroundColor = this.backgroundColor(rgbacolor, 10);
|
||||
const contrastRatio = this.contrast(rgbacolor, backgroundColor);
|
||||
|
||||
this.colorInputTarget.value = hexColor;
|
||||
this.colorInputTarget.dataset.colorPickerColorValue = hexColor;
|
||||
this.colorPreviewTarget.style.backgroundColor = hexColor;
|
||||
|
||||
this.handleContrastValidation(contrastRatio);
|
||||
});
|
||||
}
|
||||
|
||||
updateAvatarColors(color) {
|
||||
this.avatarTarget.style.backgroundColor = `${this.#backgroundColor(color)}`;
|
||||
this.avatarTarget.style.color = color;
|
||||
}
|
||||
|
||||
handleIconColorChange(e) {
|
||||
const selectedIcon = e.target;
|
||||
this.selectedIcon = selectedIcon;
|
||||
|
||||
const currentColor = this.colorInputTarget.value;
|
||||
|
||||
this.iconTargets.forEach(icon => {
|
||||
const iconWrapper = icon.nextElementSibling;
|
||||
iconWrapper.style.removeProperty("background-color")
|
||||
iconWrapper.style.color = "black";
|
||||
});
|
||||
|
||||
this.updateSelectedIconColor(currentColor);
|
||||
}
|
||||
|
||||
handleIconChange(e) {
|
||||
const iconSVG = e.currentTarget.closest('label').querySelector('svg').cloneNode(true);
|
||||
this.avatarTarget.innerHTML = '';
|
||||
iconSVG.style.padding = "0px"
|
||||
iconSVG.classList.add("w-8","h-8")
|
||||
this.avatarTarget.appendChild(iconSVG);
|
||||
}
|
||||
|
||||
updateSelectedIconColor(color) {
|
||||
if (this.selectedIcon) {
|
||||
const iconWrapper = this.selectedIcon.nextElementSibling;
|
||||
iconWrapper.style.backgroundColor = `${this.#backgroundColor(color)}`;
|
||||
iconWrapper.style.color = color;
|
||||
}
|
||||
}
|
||||
|
||||
handleColorChange(e) {
|
||||
const color = e.currentTarget.value;
|
||||
this.colorInputTarget.value = color;
|
||||
this.colorPreviewTarget.style.backgroundColor = color;
|
||||
this.updateAvatarColors(color);
|
||||
this.updateSelectedIconColor(color);
|
||||
}
|
||||
|
||||
handleContrastValidation(contrastRatio) {
|
||||
if (contrastRatio < 4.5) {
|
||||
this.colorInputTarget.setCustomValidity("Poor contrast, choose darker color or auto-adjust.");
|
||||
|
||||
this.validationMessageTarget.classList.remove("hidden");
|
||||
} else {
|
||||
this.colorInputTarget.setCustomValidity("");
|
||||
this.validationMessageTarget.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
autoAdjust(e){
|
||||
const currentRGBA = this.picker.getColor();
|
||||
const adjustedRGBA = this.darkenColor(currentRGBA).toString();
|
||||
this.picker.setColor(adjustedRGBA);
|
||||
}
|
||||
|
||||
handleParentChange(e) {
|
||||
const parent = e.currentTarget.value;
|
||||
const display = typeof parent === "string" && parent !== "" ? "none" : "flex";
|
||||
this.selectionTarget.style.display = display;
|
||||
}
|
||||
|
||||
backgroundColor([r,g,b,a], percentage) {
|
||||
const mixedR = Math.round((r * (percentage / 100)) + (255 * (1 - percentage / 100)));
|
||||
const mixedG = Math.round((g * (percentage / 100)) + (255 * (1 - percentage / 100)));
|
||||
const mixedB = Math.round((b * (percentage / 100)) + (255 * (1 - percentage / 100)));
|
||||
return [mixedR, mixedG, mixedB];
|
||||
}
|
||||
|
||||
luminance([r,g,b]) {
|
||||
const toLinear = c => {
|
||||
const scaled = c / 255;
|
||||
return scaled <= 0.04045
|
||||
? scaled / 12.92
|
||||
: ((scaled + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
||||
}
|
||||
|
||||
contrast(foregroundColor, backgroundColor) {
|
||||
const fgLum = this.luminance(foregroundColor);
|
||||
const bgLum = this.luminance(backgroundColor);
|
||||
const [l1, l2] = [Math.max(fgLum, bgLum), Math.min(fgLum, bgLum)];
|
||||
return (l1 + 0.05) / (l2 + 0.05);
|
||||
}
|
||||
|
||||
darkenColor(color) {
|
||||
let darkened = color.toRGBA();
|
||||
const backgroundColor = this.backgroundColor(darkened, 10);
|
||||
let contrastRatio = this.contrast(darkened, backgroundColor);
|
||||
|
||||
while (contrastRatio < 4.5 && (darkened[0] > 0 || darkened[1] > 0 || darkened[2] > 0)) {
|
||||
darkened = [
|
||||
Math.max(0, darkened[0] - 10),
|
||||
Math.max(0, darkened[1] - 10),
|
||||
Math.max(0, darkened[2] - 10),
|
||||
darkened[3]
|
||||
];
|
||||
contrastRatio = this.contrast(darkened, backgroundColor);
|
||||
}
|
||||
|
||||
return `rgba(${darkened.join(", ")})`;
|
||||
}
|
||||
|
||||
showPaletteSection() {
|
||||
this.initPicker();
|
||||
this.colorsSectionTarget.classList.add('hidden');
|
||||
this.paletteSectionTarget.classList.remove('hidden');
|
||||
this.pickerSectionTarget.classList.remove('hidden');
|
||||
this.picker.show();
|
||||
}
|
||||
|
||||
showColorsSection() {
|
||||
this.colorsSectionTarget.classList.remove('hidden');
|
||||
this.paletteSectionTarget.classList.add('hidden');
|
||||
this.pickerSectionTarget.classList.add('hidden');
|
||||
if (this.picker) {
|
||||
this.picker.destroyAndRemove();
|
||||
}
|
||||
}
|
||||
|
||||
toggleSections() {
|
||||
if (this.colorsSectionTarget.classList.contains('hidden')) {
|
||||
this.showColorsSection();
|
||||
} else {
|
||||
this.showPaletteSection();
|
||||
}
|
||||
}
|
||||
|
||||
#backgroundColor(color) {
|
||||
return `color-mix(in oklab, ${color} 10%, transparent)`;
|
||||
}
|
||||
}
|
||||
60
app/javascript/controllers/chat_controller.js
Normal file
60
app/javascript/controllers/chat_controller.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["messages", "form", "input"];
|
||||
|
||||
connect() {
|
||||
this.#configureAutoScroll();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.messagesObserver) {
|
||||
this.messagesObserver.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
autoResize() {
|
||||
const input = this.inputTarget;
|
||||
const lineHeight = 20; // text-sm line-height (14px * 1.429 ≈ 20px)
|
||||
const maxLines = 3; // 3 lines = 60px total
|
||||
|
||||
input.style.height = "auto";
|
||||
input.style.height = `${Math.min(input.scrollHeight, lineHeight * maxLines)}px`;
|
||||
input.style.overflowY =
|
||||
input.scrollHeight > lineHeight * maxLines ? "auto" : "hidden";
|
||||
}
|
||||
|
||||
submitSampleQuestion(e) {
|
||||
this.inputTarget.value = e.target.dataset.chatQuestionParam;
|
||||
|
||||
setTimeout(() => {
|
||||
this.formTarget.requestSubmit();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// Newlines require shift+enter, otherwise submit the form (same functionality as ChatGPT and others)
|
||||
handleInputKeyDown(e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.formTarget.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
#configureAutoScroll() {
|
||||
this.messagesObserver = new MutationObserver((_mutations) => {
|
||||
if (this.hasMessagesTarget) {
|
||||
this.#scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to entire sidebar for changes, always try to scroll to the bottom
|
||||
this.messagesObserver.observe(this.element, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
#scrollToBottom = () => {
|
||||
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight;
|
||||
};
|
||||
}
|
||||
@@ -21,14 +21,8 @@ export default class extends Controller {
|
||||
|
||||
handleColorChange(e) {
|
||||
const color = e.currentTarget.value;
|
||||
this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 5%, white)`;
|
||||
this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 10%, white)`;
|
||||
this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`;
|
||||
this.avatarTarget.style.color = color;
|
||||
}
|
||||
|
||||
handleParentChange(e) {
|
||||
const parent = e.currentTarget.value;
|
||||
const visibility = typeof parent === "string" && parent !== "" ? "hidden" : "visible"
|
||||
this.selectionTarget.style.visibility = visibility
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { Controller } from "@hotwired/stimulus";
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "list", "emptyMessage"];
|
||||
|
||||
connect() {
|
||||
this.inputTarget.focus();
|
||||
}
|
||||
|
||||
filter() {
|
||||
const filterValue = this.inputTarget.value.toLowerCase();
|
||||
const items = this.listTarget.querySelectorAll(".filterable-item");
|
||||
|
||||
@@ -2,17 +2,75 @@ import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="sidebar"
|
||||
export default class extends Controller {
|
||||
static values = { userId: String };
|
||||
static targets = ["panel", "content"];
|
||||
static values = {
|
||||
userId: String,
|
||||
config: Object,
|
||||
};
|
||||
|
||||
toggle() {
|
||||
this.panelTarget.classList.toggle("w-0");
|
||||
this.panelTarget.classList.toggle("opacity-0");
|
||||
this.panelTarget.classList.toggle("w-[260px]");
|
||||
this.panelTarget.classList.toggle("opacity-100");
|
||||
this.contentTarget.classList.toggle("max-w-4xl");
|
||||
this.contentTarget.classList.toggle("max-w-5xl");
|
||||
static targets = ["leftPanel", "leftPanelMobile", "rightPanel", "content"];
|
||||
|
||||
initialize() {
|
||||
this.leftPanelOpen = this.configValue.left_panel.is_open;
|
||||
this.rightPanelOpen = this.configValue.right_panel.is_open;
|
||||
}
|
||||
|
||||
toggleLeftPanel() {
|
||||
this.leftPanelOpen = !this.leftPanelOpen;
|
||||
this.#updatePanelWidths();
|
||||
this.#persistPreference("show_sidebar", this.leftPanelOpen);
|
||||
}
|
||||
|
||||
toggleLeftPanelMobile() {
|
||||
if (this.leftPanelOpen) {
|
||||
this.leftPanelMobileTarget.classList.remove("hidden");
|
||||
this.leftPanelOpen = false;
|
||||
} else {
|
||||
this.leftPanelMobileTarget.classList.add("hidden");
|
||||
this.leftPanelOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
toggleRightPanel() {
|
||||
this.rightPanelOpen = !this.rightPanelOpen;
|
||||
this.#updatePanelWidths();
|
||||
this.#persistPreference("show_ai_sidebar", this.rightPanelOpen);
|
||||
}
|
||||
|
||||
#updatePanelWidths() {
|
||||
this.leftPanelTarget.style.width = `${this.#leftPanelWidth()}px`;
|
||||
this.rightPanelTarget.style.width = `${this.#rightPanelWidth()}px`;
|
||||
this.rightPanelTarget.style.overflow = this.#rightPanelOverflow();
|
||||
}
|
||||
|
||||
#leftPanelWidth() {
|
||||
if (this.leftPanelOpen) {
|
||||
return this.configValue.left_panel.min_width;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
#rightPanelWidth() {
|
||||
if (this.rightPanelOpen) {
|
||||
if (this.leftPanelOpen) {
|
||||
return this.configValue.right_panel.min_width;
|
||||
}
|
||||
|
||||
return this.configValue.right_panel.max_width;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
#rightPanelOverflow() {
|
||||
if (this.rightPanelOpen) {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
return "hidden";
|
||||
}
|
||||
|
||||
#persistPreference(field, value) {
|
||||
fetch(`/users/${this.userIdValue}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
@@ -21,7 +79,7 @@ export default class extends Controller {
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
"user[show_sidebar]": !this.panelTarget.classList.contains("w-0"),
|
||||
[`user[${field}]`]: value,
|
||||
}).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
73
app/javascript/controllers/theme_controller.js
Normal file
73
app/javascript/controllers/theme_controller.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { userPreference: String }
|
||||
|
||||
connect() {
|
||||
this.applyTheme()
|
||||
this.startSystemThemeListener()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.stopSystemThemeListener()
|
||||
}
|
||||
|
||||
// Called automatically by Stimulus when the userPreferenceValue changes (e.g., after form submit/page reload)
|
||||
userPreferenceValueChanged() {
|
||||
this.applyTheme()
|
||||
}
|
||||
|
||||
// Called when a theme radio button is clicked
|
||||
updateTheme(event) {
|
||||
const selectedTheme = event.currentTarget.value
|
||||
if (selectedTheme === "system") {
|
||||
this.setTheme(this.systemPrefersDark())
|
||||
} else if (selectedTheme === "dark") {
|
||||
this.setTheme(true)
|
||||
} else {
|
||||
this.setTheme(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Applies theme based on the userPreferenceValue (from server)
|
||||
applyTheme() {
|
||||
if (this.userPreferenceValue === "system") {
|
||||
this.setTheme(this.systemPrefersDark())
|
||||
} else if (this.userPreferenceValue === "dark") {
|
||||
this.setTheme(true)
|
||||
} else {
|
||||
this.setTheme(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Sets or removes the data-theme attribute
|
||||
setTheme(isDark) {
|
||||
if (isDark) {
|
||||
document.documentElement.setAttribute("data-theme", "dark")
|
||||
} else {
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
}
|
||||
}
|
||||
|
||||
systemPrefersDark() {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
}
|
||||
|
||||
handleSystemThemeChange = (event) => {
|
||||
// Only apply system theme changes if the user preference is currently 'system'
|
||||
if (this.userPreferenceValue === "system") {
|
||||
this.setTheme(event.matches)
|
||||
}
|
||||
}
|
||||
|
||||
startSystemThemeListener() {
|
||||
this.darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
this.darkMediaQuery.addEventListener("change", this.handleSystemThemeChange)
|
||||
}
|
||||
|
||||
stopSystemThemeListener() {
|
||||
if (this.darkMediaQuery) {
|
||||
this.darkMediaQuery.removeEventListener("change", this.handleSystemThemeChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,7 +446,7 @@ export default class extends Controller {
|
||||
|
||||
get _margin() {
|
||||
if (this.useLabelsValue) {
|
||||
return { top: 20, right: 0, bottom: 30, left: 0 };
|
||||
return { top: 20, right: 0, bottom: 10, left: 0 };
|
||||
}
|
||||
return { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
# Automatically retry jobs that encountered a deadlock
|
||||
# retry_on ActiveRecord::Deadlocked
|
||||
|
||||
# Most jobs are safe to ignore if the underlying records are no longer available
|
||||
# discard_on ActiveJob::DeserializationError
|
||||
retry_on ActiveRecord::Deadlocked
|
||||
discard_on ActiveJob::DeserializationError
|
||||
queue_as :low_priority # default queue
|
||||
end
|
||||
|
||||
7
app/jobs/assistant_response_job.rb
Normal file
7
app/jobs/assistant_response_job.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class AssistantResponseJob < ApplicationJob
|
||||
queue_as :high_priority
|
||||
|
||||
def perform(message)
|
||||
message.request_response
|
||||
end
|
||||
end
|
||||
@@ -1,31 +0,0 @@
|
||||
class AutoUpgradeJob < ApplicationJob
|
||||
queue_as :latency_low
|
||||
|
||||
def perform(*args)
|
||||
raise_if_disabled
|
||||
|
||||
return Rails.logger.info "Skipping auto-upgrades because app is set to manual upgrades. Please set UPGRADES_MODE=auto to enable auto-upgrades" if Setting.upgrades_mode == "manual"
|
||||
|
||||
Rails.logger.info "Searching for available auto-upgrades..."
|
||||
|
||||
candidate = Upgrader.available_upgrade_by_type(Setting.upgrades_target)
|
||||
|
||||
if candidate
|
||||
if Rails.cache.read("last_auto_upgrade_commit_sha") == candidate.commit_sha
|
||||
Rails.logger.info "Skipping auto upgrade: #{candidate.type} #{candidate.commit_sha} deploy in progress"
|
||||
return
|
||||
end
|
||||
|
||||
Rails.logger.info "Auto upgrading to #{candidate.type} #{candidate.commit_sha}..."
|
||||
Upgrader.upgrade_to(candidate)
|
||||
Rails.cache.write("last_auto_upgrade_commit_sha", candidate.commit_sha, expires_in: 1.day)
|
||||
else
|
||||
Rails.logger.info "No auto upgrade available at this time"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def raise_if_disabled
|
||||
raise "Upgrades module is disabled. Please set UPGRADES_ENABLED=true to enable upgrade features" unless ENV["UPGRADES_ENABLED"] == "true"
|
||||
end
|
||||
end
|
||||
16
app/jobs/data_cache_clear_job.rb
Normal file
16
app/jobs/data_cache_clear_job.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
class DataCacheClearJob < ApplicationJob
|
||||
queue_as :low_priority
|
||||
|
||||
def perform(family)
|
||||
ActiveRecord::Base.transaction do
|
||||
ExchangeRate.delete_all
|
||||
Security::Price.delete_all
|
||||
family.accounts.each do |account|
|
||||
account.balances.delete_all
|
||||
account.holdings.delete_all
|
||||
end
|
||||
|
||||
family.sync_later
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class DestroyJob < ApplicationJob
|
||||
queue_as :latency_low
|
||||
queue_as :low_priority
|
||||
|
||||
def perform(model)
|
||||
model.destroy
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
class EnrichTransactionBatchJob < ApplicationJob
|
||||
queue_as :latency_high
|
||||
queue_as :low_priority
|
||||
|
||||
def perform(account, batch_size = 100, offset = 0)
|
||||
enricher = Account::DataEnricher.new(account)
|
||||
enricher.enrich_transaction_batch(batch_size, offset)
|
||||
account.enrich_transaction_batch(batch_size, offset)
|
||||
end
|
||||
end
|
||||
|
||||
19
app/jobs/family_reset_job.rb
Normal file
19
app/jobs/family_reset_job.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
class FamilyResetJob < ApplicationJob
|
||||
queue_as :low_priority
|
||||
|
||||
def perform(family)
|
||||
# Delete all family data except users
|
||||
ActiveRecord::Base.transaction do
|
||||
# Delete accounts and related data
|
||||
family.accounts.destroy_all
|
||||
family.categories.destroy_all
|
||||
family.tags.destroy_all
|
||||
family.merchants.destroy_all
|
||||
family.plaid_items.destroy_all
|
||||
family.imports.destroy_all
|
||||
family.budgets.destroy_all
|
||||
|
||||
family.sync_later
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,8 @@
|
||||
class FetchSecurityInfoJob < ApplicationJob
|
||||
queue_as :latency_low
|
||||
queue_as :low_priority
|
||||
|
||||
def perform(security_id)
|
||||
return unless Security.security_info_provider.present?
|
||||
return unless Security.provider.present?
|
||||
|
||||
security = Security.find(security_id)
|
||||
|
||||
@@ -12,7 +12,7 @@ class FetchSecurityInfoJob < ApplicationJob
|
||||
params[:mic_code] = security.exchange_mic if security.exchange_mic.present?
|
||||
params[:operating_mic] = security.exchange_operating_mic if security.exchange_operating_mic.present?
|
||||
|
||||
security_info_response = Security.security_info_provider.fetch_security_info(**params)
|
||||
security_info_response = Security.provider.fetch_security_info(**params)
|
||||
|
||||
security.update(
|
||||
name: security_info_response.info.dig("name")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class ImportJob < ApplicationJob
|
||||
queue_as :latency_medium
|
||||
queue_as :high_priority
|
||||
|
||||
def perform(import)
|
||||
import.publish
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class RevertImportJob < ApplicationJob
|
||||
queue_as :latency_low
|
||||
queue_as :medium_priority
|
||||
|
||||
def perform(import)
|
||||
import.revert
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
class SyncJob < ApplicationJob
|
||||
queue_as :latency_medium
|
||||
queue_as :high_priority
|
||||
|
||||
def perform(sync)
|
||||
sleep 1 # simulate work for faster jobs
|
||||
sync.perform
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class UserPurgeJob < ApplicationJob
|
||||
queue_as :latency_low
|
||||
queue_as :low_priority
|
||||
|
||||
def perform(user)
|
||||
user.purge
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
class Account < ApplicationRecord
|
||||
include Syncable, Monetizable, Issuable, Chartable
|
||||
include Syncable, Monetizable, Chartable, Enrichable, Linkable, Convertible
|
||||
|
||||
validates :name, :balance, :currency, presence: true
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :import, optional: true
|
||||
belongs_to :plaid_account, optional: true
|
||||
|
||||
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||
@@ -14,7 +13,6 @@ class Account < ApplicationRecord
|
||||
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
|
||||
has_many :holdings, dependent: :destroy, class_name: "Account::Holding"
|
||||
has_many :balances, dependent: :destroy
|
||||
has_many :issues, as: :issuable, dependent: :destroy
|
||||
|
||||
monetize :balance, :cash_balance
|
||||
|
||||
@@ -75,12 +73,20 @@ class Account < ApplicationRecord
|
||||
def sync_data(start_date: nil)
|
||||
update!(last_synced_at: Time.current)
|
||||
|
||||
Syncer.new(self, start_date: start_date).run
|
||||
Rails.logger.info("Auto-matching transfers")
|
||||
family.auto_match_transfers!
|
||||
|
||||
Rails.logger.info("Processing balances (#{linked? ? 'reverse' : 'forward'})")
|
||||
sync_balances
|
||||
|
||||
if enrichable?
|
||||
Rails.logger.info("Enriching transaction data")
|
||||
enrich_data
|
||||
end
|
||||
end
|
||||
|
||||
def post_sync
|
||||
broadcast_remove_to(family, target: "syncing-notice")
|
||||
resolve_stale_issues
|
||||
accountable.post_sync
|
||||
end
|
||||
|
||||
@@ -93,10 +99,6 @@ class Account < ApplicationRecord
|
||||
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
|
||||
end
|
||||
|
||||
def enrich_data
|
||||
DataEnricher.new(self).run
|
||||
end
|
||||
|
||||
def update_with_sync!(attributes)
|
||||
should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance
|
||||
|
||||
@@ -123,11 +125,14 @@ class Account < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def sparkline_series
|
||||
cache_key = family.build_cache_key("#{id}_sparkline")
|
||||
|
||||
Rails.cache.fetch(cache_key) do
|
||||
balance_series
|
||||
end
|
||||
def start_date
|
||||
first_entry_date = entries.minimum(:date) || Date.current
|
||||
first_entry_date - 1.day
|
||||
end
|
||||
|
||||
private
|
||||
def sync_balances
|
||||
strategy = linked? ? :reverse : :forward
|
||||
Balance::Syncer.new(self, strategy: strategy).sync_balances
|
||||
end
|
||||
end
|
||||
|
||||
35
app/models/account/balance/base_calculator.rb
Normal file
35
app/models/account/balance/base_calculator.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class Account::Balance::BaseCalculator
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def calculate
|
||||
Rails.logger.tagged(self.class.name) do
|
||||
calculate_balances
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def sync_cache
|
||||
@sync_cache ||= Account::Balance::SyncCache.new(account)
|
||||
end
|
||||
|
||||
def build_balance(date, cash_balance, holdings_value)
|
||||
Account::Balance.new(
|
||||
account_id: account.id,
|
||||
date: date,
|
||||
balance: holdings_value + cash_balance,
|
||||
cash_balance: cash_balance,
|
||||
currency: account.currency
|
||||
)
|
||||
end
|
||||
|
||||
def calculate_next_balance(prior_balance, transactions, direction: :forward)
|
||||
flows = transactions.sum(&:amount)
|
||||
negated = direction == :forward ? account.asset? : account.liability?
|
||||
flows *= -1 if negated
|
||||
prior_balance + flows
|
||||
end
|
||||
end
|
||||
28
app/models/account/balance/forward_calculator.rb
Normal file
28
app/models/account/balance/forward_calculator.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
class Account::Balance::ForwardCalculator < Account::Balance::BaseCalculator
|
||||
private
|
||||
def calculate_balances
|
||||
current_cash_balance = 0
|
||||
next_cash_balance = nil
|
||||
|
||||
@balances = []
|
||||
|
||||
account.start_date.upto(Date.current).each do |date|
|
||||
entries = sync_cache.get_entries(date)
|
||||
holdings = sync_cache.get_holdings(date)
|
||||
holdings_value = holdings.sum(&:amount)
|
||||
valuation = sync_cache.get_valuation(date)
|
||||
|
||||
next_cash_balance = if valuation
|
||||
valuation.amount - holdings_value
|
||||
else
|
||||
calculate_next_balance(current_cash_balance, entries, direction: :forward)
|
||||
end
|
||||
|
||||
@balances << build_balance(date, next_cash_balance, holdings_value)
|
||||
|
||||
current_cash_balance = next_cash_balance
|
||||
end
|
||||
|
||||
@balances
|
||||
end
|
||||
end
|
||||
32
app/models/account/balance/reverse_calculator.rb
Normal file
32
app/models/account/balance/reverse_calculator.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
class Account::Balance::ReverseCalculator < Account::Balance::BaseCalculator
|
||||
private
|
||||
def calculate_balances
|
||||
current_cash_balance = account.cash_balance
|
||||
previous_cash_balance = nil
|
||||
|
||||
@balances = []
|
||||
|
||||
Date.current.downto(account.start_date).map do |date|
|
||||
entries = sync_cache.get_entries(date)
|
||||
holdings = sync_cache.get_holdings(date)
|
||||
holdings_value = holdings.sum(&:amount)
|
||||
valuation = sync_cache.get_valuation(date)
|
||||
|
||||
previous_cash_balance = if valuation
|
||||
valuation.amount - holdings_value
|
||||
else
|
||||
calculate_next_balance(current_cash_balance, entries, direction: :reverse)
|
||||
end
|
||||
|
||||
if valuation.present?
|
||||
@balances << build_balance(date, previous_cash_balance, holdings_value)
|
||||
else
|
||||
@balances << build_balance(date, current_cash_balance, holdings_value)
|
||||
end
|
||||
|
||||
current_cash_balance = previous_cash_balance
|
||||
end
|
||||
|
||||
@balances
|
||||
end
|
||||
end
|
||||
46
app/models/account/balance/sync_cache.rb
Normal file
46
app/models/account/balance/sync_cache.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
class Account::Balance::SyncCache
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def get_valuation(date)
|
||||
converted_entries.find { |e| e.date == date && e.account_valuation? }
|
||||
end
|
||||
|
||||
def get_holdings(date)
|
||||
converted_holdings.select { |h| h.date == date }
|
||||
end
|
||||
|
||||
def get_entries(date)
|
||||
converted_entries.select { |e| e.date == date && (e.account_transaction? || e.account_trade?) }
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account
|
||||
|
||||
def converted_entries
|
||||
@converted_entries ||= account.entries.order(:date).to_a.map do |e|
|
||||
converted_entry = e.dup
|
||||
converted_entry.amount = converted_entry.amount_money.exchange_to(
|
||||
account.currency,
|
||||
date: e.date,
|
||||
fallback_rate: 1
|
||||
).amount
|
||||
converted_entry.currency = account.currency
|
||||
converted_entry
|
||||
end
|
||||
end
|
||||
|
||||
def converted_holdings
|
||||
@converted_holdings ||= account.holdings.map do |h|
|
||||
converted_holding = h.dup
|
||||
converted_holding.amount = converted_holding.amount_money.exchange_to(
|
||||
account.currency,
|
||||
date: h.date,
|
||||
fallback_rate: 1
|
||||
).amount
|
||||
converted_holding.currency = account.currency
|
||||
converted_holding
|
||||
end
|
||||
end
|
||||
end
|
||||
71
app/models/account/balance/syncer.rb
Normal file
71
app/models/account/balance/syncer.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
class Account::Balance::Syncer
|
||||
attr_reader :account, :strategy
|
||||
|
||||
def initialize(account, strategy:)
|
||||
@account = account
|
||||
@strategy = strategy
|
||||
end
|
||||
|
||||
def sync_balances
|
||||
Account::Balance.transaction do
|
||||
sync_holdings
|
||||
calculate_balances
|
||||
|
||||
Rails.logger.info("Persisting #{@balances.size} balances")
|
||||
persist_balances
|
||||
|
||||
purge_stale_balances
|
||||
|
||||
if strategy == :forward
|
||||
update_account_info
|
||||
end
|
||||
|
||||
account.sync_required_exchange_rates
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def sync_holdings
|
||||
@holdings = Account::Holding::Syncer.new(account, strategy: strategy).sync_holdings
|
||||
end
|
||||
|
||||
def update_account_info
|
||||
calculated_balance = @balances.sort_by(&:date).last&.balance || 0
|
||||
calculated_holdings_value = @holdings.select { |h| h.date == Date.current }.sum(&:amount) || 0
|
||||
calculated_cash_balance = calculated_balance - calculated_holdings_value
|
||||
|
||||
Rails.logger.info("Balance update: cash=#{calculated_cash_balance}, total=#{calculated_balance}")
|
||||
|
||||
account.update!(
|
||||
balance: calculated_balance,
|
||||
cash_balance: calculated_cash_balance
|
||||
)
|
||||
end
|
||||
|
||||
def calculate_balances
|
||||
@balances = calculator.calculate
|
||||
end
|
||||
|
||||
def persist_balances
|
||||
current_time = Time.now
|
||||
account.balances.upsert_all(
|
||||
@balances.map { |b| b.attributes
|
||||
.slice("date", "balance", "cash_balance", "currency")
|
||||
.merge("updated_at" => current_time) },
|
||||
unique_by: %i[account_id date currency]
|
||||
)
|
||||
end
|
||||
|
||||
def purge_stale_balances
|
||||
deleted_count = account.balances.delete_by("date < ?", account.start_date)
|
||||
Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0
|
||||
end
|
||||
|
||||
def calculator
|
||||
if strategy == :reverse
|
||||
Account::Balance::ReverseCalculator.new(account)
|
||||
else
|
||||
Account::Balance::ForwardCalculator.new(account)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,121 +0,0 @@
|
||||
class Account::BalanceCalculator
|
||||
def initialize(account, holdings: nil)
|
||||
@account = account
|
||||
@holdings = holdings || []
|
||||
end
|
||||
|
||||
def calculate(reverse: false, start_date: nil)
|
||||
cash_balances = reverse ? reverse_cash_balances : forward_cash_balances
|
||||
|
||||
cash_balances.map do |balance|
|
||||
holdings_value = converted_holdings.select { |h| h.date == balance.date }.sum(&:amount)
|
||||
balance.balance = balance.balance + holdings_value
|
||||
balance
|
||||
end.compact
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account, :holdings
|
||||
|
||||
def oldest_date
|
||||
converted_entries.first ? converted_entries.first.date - 1.day : Date.current
|
||||
end
|
||||
|
||||
def reverse_cash_balances
|
||||
prior_balance = account.cash_balance
|
||||
|
||||
Date.current.downto(oldest_date).map do |date|
|
||||
entries_for_date = converted_entries.select { |e| e.date == date }
|
||||
holdings_for_date = converted_holdings.select { |h| h.date == date }
|
||||
|
||||
valuation = entries_for_date.find { |e| e.account_valuation? }
|
||||
|
||||
current_balance = if valuation
|
||||
# To get this to a cash valuation, we back out holdings value on day
|
||||
valuation.amount - holdings_for_date.sum(&:amount)
|
||||
else
|
||||
transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? }
|
||||
|
||||
calculate_balance(prior_balance, transactions)
|
||||
end
|
||||
|
||||
balance_record = Account::Balance.new(
|
||||
account: account,
|
||||
date: date,
|
||||
balance: valuation ? current_balance : prior_balance,
|
||||
cash_balance: valuation ? current_balance : prior_balance,
|
||||
currency: account.currency
|
||||
)
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
balance_record
|
||||
end
|
||||
end
|
||||
|
||||
def forward_cash_balances
|
||||
prior_balance = 0
|
||||
current_balance = nil
|
||||
|
||||
oldest_date.upto(Date.current).map do |date|
|
||||
entries_for_date = converted_entries.select { |e| e.date == date }
|
||||
holdings_for_date = converted_holdings.select { |h| h.date == date }
|
||||
|
||||
valuation = entries_for_date.find { |e| e.account_valuation? }
|
||||
|
||||
current_balance = if valuation
|
||||
# To get this to a cash valuation, we back out holdings value on day
|
||||
valuation.amount - holdings_for_date.sum(&:amount)
|
||||
else
|
||||
transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? }
|
||||
|
||||
calculate_balance(prior_balance, transactions, inverse: true)
|
||||
end
|
||||
|
||||
balance_record = Account::Balance.new(
|
||||
account: account,
|
||||
date: date,
|
||||
balance: current_balance,
|
||||
cash_balance: current_balance,
|
||||
currency: account.currency
|
||||
)
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
balance_record
|
||||
end
|
||||
end
|
||||
|
||||
def converted_entries
|
||||
@converted_entries ||= @account.entries.order(:date).to_a.map do |e|
|
||||
converted_entry = e.dup
|
||||
converted_entry.amount = converted_entry.amount_money.exchange_to(
|
||||
account.currency,
|
||||
date: e.date,
|
||||
fallback_rate: 1
|
||||
).amount
|
||||
converted_entry.currency = account.currency
|
||||
converted_entry
|
||||
end
|
||||
end
|
||||
|
||||
def converted_holdings
|
||||
@converted_holdings ||= holdings.map do |h|
|
||||
converted_holding = h.dup
|
||||
converted_holding.amount = converted_holding.amount_money.exchange_to(
|
||||
account.currency,
|
||||
date: h.date,
|
||||
fallback_rate: 1
|
||||
).amount
|
||||
converted_holding.currency = account.currency
|
||||
converted_holding
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_balance(prior_balance, transactions, inverse: false)
|
||||
flows = transactions.sum(&:amount)
|
||||
negated = inverse ? account.asset? : account.liability?
|
||||
flows *= -1 if negated
|
||||
prior_balance + flows
|
||||
end
|
||||
end
|
||||
@@ -2,26 +2,31 @@ module Account::Chartable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up")
|
||||
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance, interval: nil)
|
||||
raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym)
|
||||
|
||||
series_interval = interval || period.interval
|
||||
|
||||
balances = Account::Balance.find_by_sql([
|
||||
balance_series_query,
|
||||
{
|
||||
start_date: period.start_date,
|
||||
end_date: period.end_date,
|
||||
interval: period.interval,
|
||||
interval: series_interval,
|
||||
target_currency: currency
|
||||
}
|
||||
])
|
||||
|
||||
balances = gapfill_balances(balances)
|
||||
balances = invert_balances(balances) if favorable_direction == "down"
|
||||
|
||||
values = [ nil, *balances ].each_cons(2).map do |prev, curr|
|
||||
Series::Value.new(
|
||||
date: curr.date,
|
||||
date_formatted: I18n.l(curr.date, format: :long),
|
||||
trend: Trend.new(
|
||||
current: Money.new(curr.balance, currency),
|
||||
previous: prev.nil? ? nil : Money.new(prev.balance, currency),
|
||||
current: Money.new(balance_value_for(curr, view), currency),
|
||||
previous: prev.nil? ? nil : Money.new(balance_value_for(prev, view), currency),
|
||||
favorable_direction: favorable_direction
|
||||
)
|
||||
)
|
||||
@@ -30,10 +35,10 @@ module Account::Chartable
|
||||
Series.new(
|
||||
start_date: period.start_date,
|
||||
end_date: period.end_date,
|
||||
interval: period.interval,
|
||||
interval: series_interval,
|
||||
trend: Trend.new(
|
||||
current: Money.new(balances.last&.balance || 0, currency),
|
||||
previous: Money.new(balances.first&.balance || 0, currency),
|
||||
current: Money.new(balance_value_for(balances.last, view) || 0, currency),
|
||||
previous: Money.new(balance_value_for(balances.first, view) || 0, currency),
|
||||
favorable_direction: favorable_direction
|
||||
),
|
||||
values: values
|
||||
@@ -51,6 +56,8 @@ module Account::Chartable
|
||||
SELECT
|
||||
d.date,
|
||||
SUM(CASE WHEN accounts.classification = 'asset' THEN ab.balance ELSE -ab.balance END * COALESCE(er.rate, 1)) as balance,
|
||||
SUM(CASE WHEN accounts.classification = 'asset' THEN ab.cash_balance ELSE -ab.cash_balance END * COALESCE(er.rate, 1)) as cash_balance,
|
||||
SUM(CASE WHEN accounts.classification = 'asset' THEN ab.balance - ab.cash_balance ELSE 0 END * COALESCE(er.rate, 1)) as holdings_balance,
|
||||
COUNT(CASE WHEN accounts.currency <> :target_currency AND er.rate IS NULL THEN 1 END) as missing_rates
|
||||
FROM dates d
|
||||
LEFT JOIN accounts ON accounts.id IN (#{all.select(:id).to_sql})
|
||||
@@ -69,19 +76,46 @@ module Account::Chartable
|
||||
SQL
|
||||
end
|
||||
|
||||
def balance_value_for(balance_record, view)
|
||||
return 0 if balance_record.nil?
|
||||
|
||||
case view.to_sym
|
||||
when :balance then balance_record.balance
|
||||
when :cash_balance then balance_record.cash_balance
|
||||
when :holdings_balance then balance_record.holdings_balance
|
||||
else
|
||||
raise ArgumentError, "Invalid view type: #{view}"
|
||||
end
|
||||
end
|
||||
|
||||
def invert_balances(balances)
|
||||
balances.map do |balance|
|
||||
balance.balance = -balance.balance
|
||||
balance.cash_balance = -balance.cash_balance
|
||||
balance.holdings_balance = -balance.holdings_balance
|
||||
balance
|
||||
end
|
||||
end
|
||||
|
||||
def gapfill_balances(balances)
|
||||
gapfilled = []
|
||||
prev = nil
|
||||
|
||||
prev_balance = nil
|
||||
|
||||
[ nil, *balances ].each_cons(2).each_with_index do |(prev, curr), index|
|
||||
if index == 0 && curr.balance.nil?
|
||||
curr.balance = 0 # Ensure all series start with a non-nil balance
|
||||
elsif curr.balance.nil?
|
||||
curr.balance = prev.balance
|
||||
balances.each do |curr|
|
||||
if prev.nil?
|
||||
# Initialize first record with zeros if nil
|
||||
curr.balance ||= 0
|
||||
curr.cash_balance ||= 0
|
||||
curr.holdings_balance ||= 0
|
||||
else
|
||||
# Copy previous values for nil fields
|
||||
curr.balance ||= prev.balance
|
||||
curr.cash_balance ||= prev.cash_balance
|
||||
curr.holdings_balance ||= prev.holdings_balance
|
||||
end
|
||||
|
||||
gapfilled << curr
|
||||
prev = curr
|
||||
end
|
||||
|
||||
gapfilled
|
||||
@@ -92,11 +126,21 @@ module Account::Chartable
|
||||
classification == "asset" ? "up" : "down"
|
||||
end
|
||||
|
||||
def balance_series(period: Period.last_30_days)
|
||||
def balance_series(period: Period.last_30_days, view: :balance, interval: nil)
|
||||
self.class.where(id: self.id).balance_series(
|
||||
currency: currency,
|
||||
period: period,
|
||||
view: view,
|
||||
interval: interval,
|
||||
favorable_direction: favorable_direction
|
||||
)
|
||||
end
|
||||
|
||||
def sparkline_series
|
||||
cache_key = family.build_cache_key("#{id}_sparkline")
|
||||
|
||||
Rails.cache.fetch(cache_key) do
|
||||
balance_series
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
27
app/models/account/convertible.rb
Normal file
27
app/models/account/convertible.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
module Account::Convertible
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def sync_required_exchange_rates
|
||||
unless requires_exchange_rates?
|
||||
Rails.logger.info("No exchange rate sync needed for account #{id}")
|
||||
return
|
||||
end
|
||||
|
||||
affected_row_count = ExchangeRate.sync_provider_rates(
|
||||
from: currency,
|
||||
to: target_currency,
|
||||
start_date: start_date,
|
||||
)
|
||||
|
||||
Rails.logger.info("Synced #{affected_row_count} exchange rates for account #{id}")
|
||||
end
|
||||
|
||||
private
|
||||
def target_currency
|
||||
family.currency
|
||||
end
|
||||
|
||||
def requires_exchange_rates?
|
||||
currency != target_currency
|
||||
end
|
||||
end
|
||||
@@ -1,68 +0,0 @@
|
||||
class Account::DataEnricher
|
||||
include Providable
|
||||
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def run
|
||||
total_unenriched = account.entries.account_transactions
|
||||
.joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
|
||||
.where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL")
|
||||
.count
|
||||
|
||||
if total_unenriched > 0
|
||||
batch_size = 50
|
||||
batches = (total_unenriched.to_f / batch_size).ceil
|
||||
|
||||
batches.times do |batch|
|
||||
EnrichTransactionBatchJob.perform_later(account, batch_size, batch * batch_size)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def enrich_transaction_batch(batch_size = 50, offset = 0)
|
||||
candidates = account.entries.account_transactions
|
||||
.includes(entryable: [ :merchant, :category ])
|
||||
.joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
|
||||
.where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL")
|
||||
.offset(offset)
|
||||
.limit(batch_size)
|
||||
|
||||
Rails.logger.info("Enriching batch of #{candidates.count} transactions for account #{account.id} (offset: #{offset})")
|
||||
|
||||
merchants = {}
|
||||
|
||||
candidates.each do |entry|
|
||||
begin
|
||||
info = self.class.synth_provider.enrich_transaction(entry.name).info
|
||||
|
||||
next unless info.present?
|
||||
|
||||
if info.name.present?
|
||||
merchant = merchants[info.name] ||= account.family.merchants.find_or_create_by(name: info.name)
|
||||
|
||||
if info.icon_url.present?
|
||||
merchant.icon_url = info.icon_url
|
||||
end
|
||||
end
|
||||
|
||||
entryable_attributes = { id: entry.entryable_id }
|
||||
entryable_attributes[:merchant_id] = merchant.id if merchant.present? && entry.entryable.merchant_id.nil?
|
||||
|
||||
Account.transaction do
|
||||
merchant.save! if merchant.present?
|
||||
entry.update!(
|
||||
enriched_at: Time.current,
|
||||
enriched_name: info.name,
|
||||
entryable_attributes: entryable_attributes
|
||||
)
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn("Error enriching transaction #{entry.id}: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
71
app/models/account/enrichable.rb
Normal file
71
app/models/account/enrichable.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
module Account::Enrichable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def enrich_data
|
||||
total_unenriched = entries.account_transactions
|
||||
.joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
|
||||
.where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL")
|
||||
.count
|
||||
|
||||
if total_unenriched > 0
|
||||
batch_size = 50
|
||||
batches = (total_unenriched.to_f / batch_size).ceil
|
||||
|
||||
batches.times do |batch|
|
||||
EnrichTransactionBatchJob.perform_now(self, batch_size, batch * batch_size)
|
||||
# EnrichTransactionBatchJob.perform_later(self, batch_size, batch * batch_size)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def enrich_transaction_batch(batch_size = 50, offset = 0)
|
||||
transactions_batch = enrichable_transactions.offset(offset).limit(batch_size)
|
||||
|
||||
Rails.logger.info("Enriching batch of #{transactions_batch.count} transactions for account #{id} (offset: #{offset})")
|
||||
|
||||
merchants = {}
|
||||
|
||||
transactions_batch.each do |transaction|
|
||||
begin
|
||||
info = transaction.fetch_enrichment_info
|
||||
|
||||
next unless info.present?
|
||||
|
||||
if info.name.present?
|
||||
merchant = merchants[info.name] ||= family.merchants.find_or_create_by(name: info.name)
|
||||
|
||||
if info.icon_url.present?
|
||||
merchant.icon_url = info.icon_url
|
||||
end
|
||||
end
|
||||
|
||||
Account.transaction do
|
||||
merchant.save! if merchant.present?
|
||||
transaction.update!(merchant: merchant) if merchant.present? && transaction.merchant_id.nil?
|
||||
|
||||
transaction.entry.update!(
|
||||
enriched_at: Time.current,
|
||||
enriched_name: info.name,
|
||||
)
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn("Error enriching transaction #{transaction.id}: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def enrichable?
|
||||
family.data_enrichment_enabled? || (linked? && Rails.application.config.app_mode.hosted?)
|
||||
end
|
||||
|
||||
def enrichable_transactions
|
||||
transactions.active
|
||||
.includes(:merchant, :category)
|
||||
.where(
|
||||
"account_entries.enriched_at IS NULL",
|
||||
"OR merchant_id IS NULL",
|
||||
"OR category_id IS NULL"
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -31,20 +31,6 @@ class Account::EntrySearch
|
||||
query
|
||||
end
|
||||
|
||||
def apply_type_filter(scope, types)
|
||||
return scope if types.blank?
|
||||
|
||||
query = scope
|
||||
|
||||
if types.include?("income") && !types.include?("expense")
|
||||
query = query.where("account_entries.amount < 0")
|
||||
elsif types.include?("expense") && !types.include?("income")
|
||||
query = query.where("account_entries.amount >= 0")
|
||||
end
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
def apply_amount_filter(scope, amount, amount_operator)
|
||||
return scope if amount.blank? || amount_operator.blank?
|
||||
|
||||
@@ -76,7 +62,6 @@ class Account::EntrySearch
|
||||
query = scope.joins(:account)
|
||||
query = self.class.apply_search_filter(query, search)
|
||||
query = self.class.apply_date_filters(query, start_date, end_date)
|
||||
query = self.class.apply_type_filter(query, types)
|
||||
query = self.class.apply_amount_filter(query, amount, amount_operator)
|
||||
query = self.class.apply_accounts_filter(query, accounts, account_ids)
|
||||
query
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Account::Holding < ApplicationRecord
|
||||
include Monetizable
|
||||
include Monetizable, Gapfillable
|
||||
|
||||
monetize :amount
|
||||
|
||||
|
||||
63
app/models/account/holding/base_calculator.rb
Normal file
63
app/models/account/holding/base_calculator.rb
Normal file
@@ -0,0 +1,63 @@
|
||||
class Account::Holding::BaseCalculator
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def calculate
|
||||
Rails.logger.tagged(self.class.name) do
|
||||
holdings = calculate_holdings
|
||||
Account::Holding.gapfill(holdings)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def portfolio_cache
|
||||
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account)
|
||||
end
|
||||
|
||||
def empty_portfolio
|
||||
securities = portfolio_cache.get_securities
|
||||
securities.each_with_object({}) { |security, hash| hash[security.id] = 0 }
|
||||
end
|
||||
|
||||
def generate_starting_portfolio
|
||||
empty_portfolio
|
||||
end
|
||||
|
||||
def transform_portfolio(previous_portfolio, trade_entries, direction: :forward)
|
||||
new_quantities = previous_portfolio.dup
|
||||
|
||||
trade_entries.each do |trade_entry|
|
||||
trade = trade_entry.entryable
|
||||
security_id = trade.security_id
|
||||
qty_change = trade.qty
|
||||
qty_change = qty_change * -1 if direction == :reverse
|
||||
new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change
|
||||
end
|
||||
|
||||
new_quantities
|
||||
end
|
||||
|
||||
def build_holdings(portfolio, date)
|
||||
portfolio.map do |security_id, qty|
|
||||
price = portfolio_cache.get_price(security_id, date)
|
||||
|
||||
if price.nil?
|
||||
Rails.logger.warn "No price found for security #{security_id} on #{date}"
|
||||
next
|
||||
end
|
||||
|
||||
Account::Holding.new(
|
||||
account_id: account.id,
|
||||
security_id: security_id,
|
||||
date: date,
|
||||
qty: qty,
|
||||
price: price.price,
|
||||
currency: price.currency,
|
||||
amount: qty * price.price
|
||||
)
|
||||
end.compact
|
||||
end
|
||||
end
|
||||
21
app/models/account/holding/forward_calculator.rb
Normal file
21
app/models/account/holding/forward_calculator.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
class Account::Holding::ForwardCalculator < Account::Holding::BaseCalculator
|
||||
private
|
||||
def portfolio_cache
|
||||
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account)
|
||||
end
|
||||
|
||||
def calculate_holdings
|
||||
current_portfolio = generate_starting_portfolio
|
||||
next_portfolio = {}
|
||||
holdings = []
|
||||
|
||||
account.start_date.upto(Date.current).each do |date|
|
||||
trades = portfolio_cache.get_trades(date: date)
|
||||
next_portfolio = transform_portfolio(current_portfolio, trades, direction: :forward)
|
||||
holdings += build_holdings(next_portfolio, date)
|
||||
current_portfolio = next_portfolio
|
||||
end
|
||||
|
||||
holdings
|
||||
end
|
||||
end
|
||||
38
app/models/account/holding/gapfillable.rb
Normal file
38
app/models/account/holding/gapfillable.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
module Account::Holding::Gapfillable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def gapfill(holdings)
|
||||
filled_holdings = []
|
||||
|
||||
holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings|
|
||||
next if security_holdings.empty?
|
||||
|
||||
sorted = security_holdings.sort_by(&:date)
|
||||
previous_holding = sorted.first
|
||||
|
||||
sorted.first.date.upto(Date.current) do |date|
|
||||
holding = security_holdings.find { |h| h.date == date }
|
||||
|
||||
if holding
|
||||
filled_holdings << holding
|
||||
previous_holding = holding
|
||||
else
|
||||
# Create a new holding based on the previous day's data
|
||||
filled_holdings << Account::Holding.new(
|
||||
account: previous_holding.account,
|
||||
security: previous_holding.security,
|
||||
date: date,
|
||||
qty: previous_holding.qty,
|
||||
price: previous_holding.price,
|
||||
currency: previous_holding.currency,
|
||||
amount: previous_holding.amount
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
filled_holdings
|
||||
end
|
||||
end
|
||||
end
|
||||
131
app/models/account/holding/portfolio_cache.rb
Normal file
131
app/models/account/holding/portfolio_cache.rb
Normal file
@@ -0,0 +1,131 @@
|
||||
class Account::Holding::PortfolioCache
|
||||
attr_reader :account, :use_holdings
|
||||
|
||||
class SecurityNotFound < StandardError
|
||||
def initialize(security_id, account_id)
|
||||
super("Security id=#{security_id} not found in portfolio cache for account #{account_id}. This should not happen unless securities were preloaded incorrectly.")
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(account, use_holdings: false)
|
||||
@account = account
|
||||
@use_holdings = use_holdings
|
||||
load_prices
|
||||
end
|
||||
|
||||
def get_trades(date: nil)
|
||||
if date.blank?
|
||||
trades
|
||||
else
|
||||
trades.select { |t| t.date == date }
|
||||
end
|
||||
end
|
||||
|
||||
def get_price(security_id, date)
|
||||
security = @security_cache[security_id]
|
||||
raise SecurityNotFound.new(security_id, account.id) unless security
|
||||
|
||||
price = security[:prices].select { |p| p.price.date == date }.min_by(&:priority)&.price
|
||||
|
||||
return nil unless price
|
||||
|
||||
price_money = Money.new(price.price, price.currency)
|
||||
|
||||
converted_amount = price_money.exchange_to(account.currency, fallback_rate: 1).amount
|
||||
|
||||
Security::Price.new(
|
||||
security_id: security_id,
|
||||
date: price.date,
|
||||
price: converted_amount,
|
||||
currency: account.currency
|
||||
)
|
||||
end
|
||||
|
||||
def get_securities
|
||||
@security_cache.map { |_, v| v[:security] }
|
||||
end
|
||||
|
||||
private
|
||||
PriceWithPriority = Data.define(:price, :priority)
|
||||
|
||||
def trades
|
||||
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
|
||||
end
|
||||
|
||||
def holdings
|
||||
@holdings ||= account.holdings.chronological.to_a
|
||||
end
|
||||
|
||||
def collect_unique_securities
|
||||
unique_securities_from_trades = trades.map(&:entryable).map(&:security).uniq
|
||||
|
||||
return unique_securities_from_trades unless use_holdings
|
||||
|
||||
unique_securities_from_holdings = holdings.map(&:security).uniq
|
||||
|
||||
(unique_securities_from_trades + unique_securities_from_holdings).uniq
|
||||
end
|
||||
|
||||
# Loads all known prices for all securities in the account with priority based on source:
|
||||
# 1 - DB or provider prices
|
||||
# 2 - Trade prices
|
||||
# 3 - Holding prices
|
||||
def load_prices
|
||||
@security_cache = {}
|
||||
securities = collect_unique_securities
|
||||
|
||||
Rails.logger.info "Preloading #{securities.size} securities for account #{account.id}"
|
||||
|
||||
securities.each do |security|
|
||||
Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}"
|
||||
|
||||
# Load prices from provider to DB
|
||||
security.sync_provider_prices(start_date: account.start_date)
|
||||
|
||||
# High priority prices from DB (synced from provider)
|
||||
db_prices = security.prices.where(date: account.start_date..Date.current).map do |price|
|
||||
PriceWithPriority.new(
|
||||
price: price,
|
||||
priority: 1
|
||||
)
|
||||
end
|
||||
|
||||
# Medium priority prices from trades
|
||||
trade_prices = trades
|
||||
.select { |t| t.entryable.security_id == security.id }
|
||||
.map do |trade|
|
||||
PriceWithPriority.new(
|
||||
price: Security::Price.new(
|
||||
security: security,
|
||||
price: trade.entryable.price,
|
||||
currency: trade.entryable.currency,
|
||||
date: trade.date
|
||||
),
|
||||
priority: 2
|
||||
)
|
||||
end
|
||||
|
||||
# Low priority prices from holdings (if applicable)
|
||||
holding_prices = if use_holdings
|
||||
holdings.select { |h| h.security_id == security.id }.map do |holding|
|
||||
PriceWithPriority.new(
|
||||
price: Security::Price.new(
|
||||
security: security,
|
||||
price: holding.price,
|
||||
currency: holding.currency,
|
||||
date: holding.date
|
||||
),
|
||||
priority: 3
|
||||
)
|
||||
end
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
@security_cache[security.id] = {
|
||||
security: security,
|
||||
prices: db_prices + trade_prices + holding_prices
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
38
app/models/account/holding/reverse_calculator.rb
Normal file
38
app/models/account/holding/reverse_calculator.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
class Account::Holding::ReverseCalculator < Account::Holding::BaseCalculator
|
||||
private
|
||||
# Reverse calculators will use the existing holdings as a source of security ids and prices
|
||||
# since it is common for a provider to supply "current day" holdings but not all the historical
|
||||
# trades that make up those holdings.
|
||||
def portfolio_cache
|
||||
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account, use_holdings: true)
|
||||
end
|
||||
|
||||
def calculate_holdings
|
||||
current_portfolio = generate_starting_portfolio
|
||||
previous_portfolio = {}
|
||||
|
||||
holdings = []
|
||||
|
||||
Date.current.downto(account.start_date).each do |date|
|
||||
today_trades = portfolio_cache.get_trades(date: date)
|
||||
previous_portfolio = transform_portfolio(current_portfolio, today_trades, direction: :reverse)
|
||||
holdings += build_holdings(current_portfolio, date)
|
||||
current_portfolio = previous_portfolio
|
||||
end
|
||||
|
||||
holdings
|
||||
end
|
||||
|
||||
# Since this is a reverse sync, we start with today's holdings
|
||||
def generate_starting_portfolio
|
||||
holding_quantities = empty_portfolio
|
||||
|
||||
todays_holdings = account.holdings.where(date: Date.current)
|
||||
|
||||
todays_holdings.each do |holding|
|
||||
holding_quantities[holding.security_id] = holding.qty
|
||||
end
|
||||
|
||||
holding_quantities
|
||||
end
|
||||
end
|
||||
58
app/models/account/holding/syncer.rb
Normal file
58
app/models/account/holding/syncer.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
class Account::Holding::Syncer
|
||||
def initialize(account, strategy:)
|
||||
@account = account
|
||||
@strategy = strategy
|
||||
end
|
||||
|
||||
def sync_holdings
|
||||
calculate_holdings
|
||||
|
||||
Rails.logger.info("Persisting #{@holdings.size} holdings")
|
||||
persist_holdings
|
||||
|
||||
if strategy == :forward
|
||||
purge_stale_holdings
|
||||
end
|
||||
|
||||
@holdings
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account, :strategy
|
||||
|
||||
def calculate_holdings
|
||||
@holdings = calculator.calculate
|
||||
end
|
||||
|
||||
def persist_holdings
|
||||
current_time = Time.now
|
||||
|
||||
account.holdings.upsert_all(
|
||||
@holdings.map { |h| h.attributes
|
||||
.slice("date", "currency", "qty", "price", "amount", "security_id")
|
||||
.merge("account_id" => account.id, "updated_at" => current_time) },
|
||||
unique_by: %i[account_id security_id date currency]
|
||||
)
|
||||
end
|
||||
|
||||
def purge_stale_holdings
|
||||
portfolio_security_ids = account.entries.account_trades.map { |entry| entry.entryable.security_id }.uniq
|
||||
|
||||
# If there are no securities in the portfolio, delete all holdings
|
||||
if portfolio_security_ids.empty?
|
||||
Rails.logger.info("Clearing all holdings (no securities)")
|
||||
account.holdings.delete_all
|
||||
else
|
||||
deleted_count = account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account.start_date, portfolio_security_ids)
|
||||
Rails.logger.info("Purged #{deleted_count} stale holdings") if deleted_count > 0
|
||||
end
|
||||
end
|
||||
|
||||
def calculator
|
||||
if strategy == :reverse
|
||||
Account::Holding::ReverseCalculator.new(account)
|
||||
else
|
||||
Account::Holding::ForwardCalculator.new(account)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,183 +0,0 @@
|
||||
class Account::HoldingCalculator
|
||||
def initialize(account)
|
||||
@account = account
|
||||
@securities_cache = {}
|
||||
end
|
||||
|
||||
def calculate(reverse: false)
|
||||
preload_securities
|
||||
calculated_holdings = reverse ? reverse_holdings : forward_holdings
|
||||
gapfill_holdings(calculated_holdings)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account, :securities_cache
|
||||
|
||||
def reverse_holdings
|
||||
current_holding_quantities = load_current_holding_quantities
|
||||
prior_holding_quantities = {}
|
||||
|
||||
holdings = []
|
||||
|
||||
Date.current.downto(portfolio_start_date).map do |date|
|
||||
today_trades = trades.select { |t| t.date == date }
|
||||
prior_holding_quantities = calculate_portfolio(current_holding_quantities, today_trades)
|
||||
holdings += generate_holding_records(current_holding_quantities, date)
|
||||
current_holding_quantities = prior_holding_quantities
|
||||
end
|
||||
|
||||
holdings
|
||||
end
|
||||
|
||||
def forward_holdings
|
||||
prior_holding_quantities = load_empty_holding_quantities
|
||||
current_holding_quantities = {}
|
||||
|
||||
holdings = []
|
||||
|
||||
portfolio_start_date.upto(Date.current).map do |date|
|
||||
today_trades = trades.select { |t| t.date == date }
|
||||
current_holding_quantities = calculate_portfolio(prior_holding_quantities, today_trades, inverse: true)
|
||||
holdings += generate_holding_records(current_holding_quantities, date)
|
||||
prior_holding_quantities = current_holding_quantities
|
||||
end
|
||||
|
||||
holdings
|
||||
end
|
||||
|
||||
def generate_holding_records(portfolio, date)
|
||||
Rails.logger.info "[HoldingCalculator] Generating holdings for #{portfolio.size} securities on #{date}"
|
||||
|
||||
portfolio.map do |security_id, qty|
|
||||
security = securities_cache[security_id]
|
||||
|
||||
if security.blank?
|
||||
Rails.logger.error "[HoldingCalculator] Security #{security_id} not found in cache for account #{account.id}"
|
||||
next
|
||||
end
|
||||
|
||||
price = security.dig(:prices)&.find { |p| p.date == date }
|
||||
|
||||
if price.blank?
|
||||
Rails.logger.info "[HoldingCalculator] No price found for security #{security_id} on #{date}"
|
||||
next
|
||||
end
|
||||
|
||||
converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount
|
||||
|
||||
account.holdings.build(
|
||||
security: security.dig(:security),
|
||||
date: date,
|
||||
qty: qty,
|
||||
price: converted_price,
|
||||
currency: account.currency,
|
||||
amount: qty * converted_price
|
||||
)
|
||||
end.compact
|
||||
end
|
||||
|
||||
def gapfill_holdings(holdings)
|
||||
filled_holdings = []
|
||||
|
||||
holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings|
|
||||
next if security_holdings.empty?
|
||||
|
||||
sorted = security_holdings.sort_by(&:date)
|
||||
previous_holding = sorted.first
|
||||
|
||||
sorted.first.date.upto(Date.current) do |date|
|
||||
holding = security_holdings.find { |h| h.date == date }
|
||||
|
||||
if holding
|
||||
filled_holdings << holding
|
||||
previous_holding = holding
|
||||
else
|
||||
# Create a new holding based on the previous day's data
|
||||
filled_holdings << account.holdings.build(
|
||||
security: previous_holding.security,
|
||||
date: date,
|
||||
qty: previous_holding.qty,
|
||||
price: previous_holding.price,
|
||||
currency: previous_holding.currency,
|
||||
amount: previous_holding.amount
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
filled_holdings
|
||||
end
|
||||
|
||||
def trades
|
||||
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
|
||||
end
|
||||
|
||||
def portfolio_start_date
|
||||
trades.first ? trades.first.date - 1.day : Date.current
|
||||
end
|
||||
|
||||
def preload_securities
|
||||
# Get securities from trades and current holdings
|
||||
securities = trades.map(&:entryable).map(&:security).uniq
|
||||
securities += account.holdings.where(date: Date.current).map(&:security)
|
||||
securities.uniq!
|
||||
|
||||
Rails.logger.info "[HoldingCalculator] Preloading #{securities.size} securities for account #{account.id}"
|
||||
|
||||
securities.each do |security|
|
||||
begin
|
||||
Rails.logger.info "[HoldingCalculator] Loading security: ID=#{security.id} Ticker=#{security.ticker}"
|
||||
|
||||
prices = Security::Price.find_prices(
|
||||
security: security,
|
||||
start_date: portfolio_start_date,
|
||||
end_date: Date.current
|
||||
)
|
||||
|
||||
Rails.logger.info "[HoldingCalculator] Found #{prices.size} prices for security #{security.id}"
|
||||
|
||||
@securities_cache[security.id] = {
|
||||
security: security,
|
||||
prices: prices
|
||||
}
|
||||
rescue => e
|
||||
Rails.logger.error "[HoldingCalculator] Error processing security #{security.id}: #{e.message}"
|
||||
Rails.logger.error "[HoldingCalculator] Security details: #{security.attributes}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
next # Skip this security and continue with others
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_portfolio(holding_quantities, today_trades, inverse: false)
|
||||
new_quantities = holding_quantities.dup
|
||||
|
||||
today_trades.each do |trade|
|
||||
security_id = trade.entryable.security_id
|
||||
qty_change = inverse ? trade.entryable.qty : -trade.entryable.qty
|
||||
new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change
|
||||
end
|
||||
|
||||
new_quantities
|
||||
end
|
||||
|
||||
def load_empty_holding_quantities
|
||||
holding_quantities = {}
|
||||
|
||||
trades.map { |t| t.entryable.security_id }.uniq.each do |security_id|
|
||||
holding_quantities[security_id] = 0
|
||||
end
|
||||
|
||||
holding_quantities
|
||||
end
|
||||
|
||||
def load_current_holding_quantities
|
||||
holding_quantities = load_empty_holding_quantities
|
||||
|
||||
account.holdings.where(date: Date.current, currency: account.currency).map do |holding|
|
||||
holding_quantities[holding.security_id] = holding.qty
|
||||
end
|
||||
|
||||
holding_quantities
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user