Compare commits
241 Commits
v0.2.0-alp
...
v0.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da668f3dc0 | ||
|
|
cc11fec08a | ||
|
|
ce12e5b5c7 | ||
|
|
f96bb84c4c | ||
|
|
d75be2282b | ||
|
|
8539ac7dec | ||
|
|
c620d1fc1e | ||
|
|
79e1a2c0ff | ||
|
|
e9b29f6726 | ||
|
|
1b1e06070b | ||
|
|
f35b70e936 | ||
|
|
8e339dcbe0 | ||
|
|
366862fee1 | ||
|
|
73f826fdf2 | ||
|
|
999f1c5190 | ||
|
|
849c58dd3e | ||
|
|
c0e290a07e | ||
|
|
f1f2e103ce | ||
|
|
08a2d35308 | ||
|
|
a82c0303ce | ||
|
|
945a39d035 | ||
|
|
d31d5c5467 | ||
|
|
ab7f6a56f0 | ||
|
|
68d7cb5de6 | ||
|
|
fb6c6fa6bb | ||
|
|
077694bbde | ||
|
|
9e5f1574bc | ||
|
|
26197eda85 | ||
|
|
1dbf1ad74e | ||
|
|
ab632cf0be | ||
|
|
2b83fc787f | ||
|
|
536c82f2aa | ||
|
|
60925bd16c | ||
|
|
cf23673003 | ||
|
|
0dc25cda22 | ||
|
|
5eb5ec7aef | ||
|
|
331de2f997 | ||
|
|
972c850d27 | ||
|
|
78e34d68e9 | ||
|
|
21dc4b80f3 | ||
|
|
842e37658c | ||
|
|
7ba9063e04 | ||
|
|
df5f4c83fe | ||
|
|
e3ef1dd6b4 | ||
|
|
4aba9d1c0b | ||
|
|
cf014bc24f | ||
|
|
90f1ff8a0b | ||
|
|
b84a33c09d | ||
|
|
abd932c894 | ||
|
|
5b083c9e33 | ||
|
|
f498212b2d | ||
|
|
37aab45c19 | ||
|
|
c9c5eb315a | ||
|
|
75c8627577 | ||
|
|
15e8281d46 | ||
|
|
283d9cd8e2 | ||
|
|
3e06017ae1 | ||
|
|
058830591f | ||
|
|
32d826c047 | ||
|
|
bdec61f312 | ||
|
|
9c846e7de4 | ||
|
|
4c158934d0 | ||
|
|
50e5ffb257 | ||
|
|
983729cbdf | ||
|
|
89027f1fbf | ||
|
|
2a338eb01b | ||
|
|
f57fa526af | ||
|
|
b02380ac97 | ||
|
|
550991e240 | ||
|
|
4a768d0358 | ||
|
|
a1065fde83 | ||
|
|
f63aea7f87 | ||
|
|
872a480c0f | ||
|
|
1620d56e2d | ||
|
|
c1e48bd3c9 | ||
|
|
4c083fec0a | ||
|
|
2c2b600163 | ||
|
|
53f4b32c33 | ||
|
|
4bf72506d5 | ||
|
|
41873de11d | ||
|
|
46e86a9a11 | ||
|
|
ad5b0b8b7d | ||
|
|
ded42a8c33 | ||
|
|
b78fd1d755 | ||
|
|
0696e1f2f7 | ||
|
|
282c05345d | ||
|
|
0b17976256 | ||
|
|
3b0f8ae8c2 | ||
|
|
247d91b99d | ||
|
|
d428a1f954 | ||
|
|
8256d116dd | ||
|
|
de90b29201 | ||
|
|
0b4e314f58 | ||
|
|
6c8974a086 | ||
|
|
7265f58518 | ||
|
|
2a202576f8 | ||
|
|
d2a7aef6ef | ||
|
|
eabfb7aae1 | ||
|
|
2a1b5fab1a | ||
|
|
5cc592d38f | ||
|
|
8be5bb07c8 | ||
|
|
caf359deed | ||
|
|
91149ceff8 | ||
|
|
f9d4270a75 | ||
|
|
beb6e36577 | ||
|
|
217a96c02d | ||
|
|
e617d791d3 | ||
|
|
7e0ec4bd8f | ||
|
|
3140835f28 | ||
|
|
7d04ea1071 | ||
|
|
43dd16e3fb | ||
|
|
61321f6b16 | ||
|
|
0476f25952 | ||
|
|
e4a374772a | ||
|
|
44961f3628 | ||
|
|
68c570eed8 | ||
|
|
67d81f866f | ||
|
|
72fd177707 | ||
|
|
abccba3947 | ||
|
|
9808641110 | ||
|
|
9fadc6ba63 | ||
|
|
39139ce21a | ||
|
|
51e8fae26d | ||
|
|
42d2197ea1 | ||
|
|
a9c1e85a58 | ||
|
|
8c8e972dc8 | ||
|
|
ae9287ec9b | ||
|
|
aac9e5eca2 | ||
|
|
ca8bdb6241 | ||
|
|
1ae4b4d612 | ||
|
|
60f1a1e2d2 | ||
|
|
e1d3c7a4a1 | ||
|
|
195ec85d96 | ||
|
|
413ec6cbed | ||
|
|
e4e5ae9f25 | ||
|
|
5449fc49ef | ||
|
|
b50b7b30e8 | ||
|
|
871a68b5bc | ||
|
|
1f4c2165eb | ||
|
|
71598d26cb | ||
|
|
997d0355d4 | ||
|
|
2c30e18c9b | ||
|
|
307a3687e8 | ||
|
|
46e129308f | ||
|
|
5d1a2937bb | ||
|
|
b82b82ddf7 | ||
|
|
97852bc3b4 | ||
|
|
84d2aac1a5 | ||
|
|
49d3a9c7e7 | ||
|
|
b7019744a1 | ||
|
|
a9e791f94c | ||
|
|
cce373c31b | ||
|
|
0220861a3b | ||
|
|
fb6b6ce63d | ||
|
|
dba10c2bc8 | ||
|
|
b0d9891133 | ||
|
|
9d217afb9f | ||
|
|
77def1db40 | ||
|
|
a4d10097d5 | ||
|
|
7be6a372bf | ||
|
|
68617514b0 | ||
|
|
ba878c3d8b | ||
|
|
6034dfe5f5 | ||
|
|
ae30176816 | ||
|
|
7508ae55ac | ||
|
|
bb9fa56add | ||
|
|
54e46c1b4e | ||
|
|
0d09f2e3e9 | ||
|
|
f7ce2cdf89 | ||
|
|
f7e86d4c90 | ||
|
|
45add7512b | ||
|
|
9130089950 | ||
|
|
fe199f2357 | ||
|
|
bac2e64c19 | ||
|
|
4866a4f8e4 | ||
|
|
027c18297b | ||
|
|
800eb4c146 | ||
|
|
b2a56aefc1 | ||
|
|
46131fb496 | ||
|
|
49c353e10c | ||
|
|
a59ca5b7c6 | ||
|
|
ee79016e2a | ||
|
|
13cf4d70df | ||
|
|
48e306a614 | ||
|
|
a9daba16c1 | ||
|
|
2cba5177ba | ||
|
|
13bec4599f | ||
|
|
565103caf3 | ||
|
|
c456950de8 | ||
|
|
9ec94cd1fa | ||
|
|
d73e7eacce | ||
|
|
890638e06d | ||
|
|
14fd5913fe | ||
|
|
e026f68895 | ||
|
|
1b8064b9fd | ||
|
|
d592495be5 | ||
|
|
c3248cd796 | ||
|
|
76f2714006 | ||
|
|
a9b61a655b | ||
|
|
955f211fe0 | ||
|
|
570a0c7ff6 | ||
|
|
de9ffa7ca0 | ||
|
|
b5666ad7a9 | ||
|
|
fc603a1733 | ||
|
|
6c503e4d26 | ||
|
|
57a87f2850 | ||
|
|
84f069448a | ||
|
|
25e9bd4c60 | ||
|
|
a4adfed82b | ||
|
|
03e92e63a5 | ||
|
|
c1034e6edf | ||
|
|
1c2f075053 | ||
|
|
571fc4db75 | ||
|
|
c8302a6d49 | ||
|
|
c309c8abf8 | ||
|
|
242eb5cea1 | ||
|
|
6996a225ba | ||
|
|
e641cfccd4 | ||
|
|
d1b506d16c | ||
|
|
81d604f3d4 | ||
|
|
fcb95207d7 | ||
|
|
743e291d56 | ||
|
|
6105f822b7 | ||
|
|
9cc9f42bdc | ||
|
|
8b672c4062 | ||
|
|
8befb8a8b0 | ||
|
|
f15875560e | ||
|
|
951a29d923 | ||
|
|
91eedfbd1b | ||
|
|
0af5faaa9f | ||
|
|
69f6d7f8ea | ||
|
|
cbba2ba675 | ||
|
|
3bc9da4105 | ||
|
|
9522a191de | ||
|
|
ed87023c0f | ||
|
|
3d7a74862d | ||
|
|
fc3695dda9 | ||
|
|
278d04a73a | ||
|
|
31ecd3ccd4 | ||
|
|
3ef67faf7e | ||
|
|
8ba04b0330 |
@@ -1,64 +0,0 @@
|
||||
<!-- Copy this file to .cursorrules in the root of the project on your local machine if you'd like to use these rules with Cursor. -->
|
||||
|
||||
You are an expert in Ruby, Ruby on Rails, Postgres, Tailwind, Stimulus, Hotwire and Turbo and always use the latest stable versions of those technologies.
|
||||
|
||||
**Code Style and Structure**
|
||||
- Write concise, technical Ruby code with accurate examples.
|
||||
- Prefer iteration and modularization over code duplication.
|
||||
- Use descriptive variable names with auxiliary verbs (e.g., is_loading, has_error).
|
||||
- Structure files: models, controllers, views, helpers, services, jobs, mailers.
|
||||
|
||||
**Naming Conventions**
|
||||
- Use snake_case for file names and directories (e.g., app/models/user_profile.rb).
|
||||
- Use CamelCase for classes and modules (e.g., UserProfile).
|
||||
|
||||
**Ruby on Rails Usage**
|
||||
- Use Rails conventions for MVC structure.
|
||||
- Favor scopes over class methods for queries.
|
||||
- Use strong parameters for mass assignment protection.
|
||||
- Use partials to DRY up views.
|
||||
|
||||
**Syntax and Formatting**
|
||||
- Use two spaces for indentation.
|
||||
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
|
||||
- Use descriptive method names and keep methods short.
|
||||
|
||||
**Commenting Code**
|
||||
- Write clear, concise comments to explain the purpose of individual functions and methods.
|
||||
- Use comments to describe the intent and functionality of complex logic.
|
||||
- Avoid redundant comments that state the obvious.
|
||||
|
||||
**UI and Styling**
|
||||
- Use Tailwind CSS for styling.
|
||||
- Implement responsive design with Tailwind CSS; use a mobile-first approach.
|
||||
- Use Stimulus for JavaScript behavior.
|
||||
- Use Turbo for asynchronous actions and updates.
|
||||
|
||||
**Performance Optimization**
|
||||
- Use eager loading to avoid N+1 queries.
|
||||
- Cache expensive queries and partials where appropriate.
|
||||
- Use background jobs for long-running tasks.
|
||||
- Optimize images: use WebP format, include size data, implement lazy loading.
|
||||
|
||||
**Database Querying & Data Model Creation**
|
||||
- Use ActiveRecord for data querying and model creation.
|
||||
- Favor database constraints and indexes for data integrity and performance.
|
||||
- Use migrations to manage schema changes.
|
||||
|
||||
**Key Conventions**
|
||||
- Follow Rails best practices for RESTful routing.
|
||||
- Optimize for performance and security.
|
||||
- Use environment variables for configuration.
|
||||
- Write tests for models, controllers, and features.
|
||||
|
||||
**AI Guidelines**
|
||||
- Follow the user’s requirements carefully & to the letter.
|
||||
- Confirm, then write code!
|
||||
- Suggest solutions that I didn't think about—anticipate my needs
|
||||
- Focus on readability over being performant.
|
||||
- Fully implement all requested functionality.
|
||||
- Leave NO todo’s, placeholders or missing pieces.
|
||||
- Don't say things like "additional logic can be added here" — instead, add the logic.
|
||||
- Be concise. Minimize any other prose.
|
||||
- Consider new technologies and contrarian ideas, not just the conventional wisdom
|
||||
- If I ask for adjustments to code, do not repeat all of my code unnecessarily. Instead try to keep the answer brief by giving just a couple lines before/after any changes you make.
|
||||
84
.cursor/rules/project-conventions.mdc
Normal file
84
.cursor/rules/project-conventions.mdc
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
description: This rule explains the project's tech stack and code conventions
|
||||
globs: *
|
||||
---
|
||||
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
|
||||
|
||||
## Project Tech Stack
|
||||
|
||||
- Web framework: Ruby on Rails
|
||||
- Minitest + fixtures for testing
|
||||
- Propshaft for asset pipeline
|
||||
- Hotwire Turbo/Stimulus for SPA-like UI/UX
|
||||
- TailwindCSS for styles
|
||||
- Lucide Icons for icons
|
||||
- Database: PostgreSQL
|
||||
- Jobs: GoodJob
|
||||
- External
|
||||
- Payments: Stripe
|
||||
- User bank data syncing: Plaid
|
||||
- Market data: Synth (our custom API)
|
||||
|
||||
## Project conventions
|
||||
|
||||
These conventions should be used when writing code for Maybe.
|
||||
|
||||
### Convention 1: Minimize dependencies, vanilla Rails is plenty
|
||||
|
||||
Dependencies are a natural part of building software, but we aim to minimize them when possible to keep this open-source codebase easy to understand, maintain, and contribute to.
|
||||
|
||||
- Push Rails to its limits before adding new dependencies
|
||||
- When a new dependency is added, there must be a strong technical or business reason to add it
|
||||
- When adding dependencies, you should favor old and reliable over new and flashy
|
||||
|
||||
### Convention 2: Leverage POROs and concerns over "service objects"
|
||||
|
||||
This codebase adopts a "skinny controller, fat models" convention. Furthermore, we put almost _everything_ directly in the `app/models/` folder and avoid separate folders for business logic such as `app/services/`.
|
||||
|
||||
- Organize large pieces of business logic into Rails concerns and POROs (Plain ole' Ruby Objects)
|
||||
- While a Rails concern _may_ offer shared functionality (i.e. "duck types"), it can also be a "one-off" concern that is only included in one place for better organization and readability.
|
||||
- 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
|
||||
|
||||
- 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)
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
### Convention 7: 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
|
||||
134
.cursor/rules/project-design.mdc
Normal file
134
.cursor/rules/project-design.mdc
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
description: This rule explains the system architecture and data flow of the Rails app
|
||||
globs: *
|
||||
---
|
||||
|
||||
This file outlines how the codebase is structured and how data flows through the app.
|
||||
|
||||
This is a personal finance application built in Ruby on Rails. The primary domain entities for this app are outlined below. For an authoritative overview of the relationships, [schema.rb](mdc:db/schema.rb) is the source of truth.
|
||||
|
||||
## App Modes
|
||||
|
||||
The Maybe app runs in two distinct "modes", dictated by `Rails.application.config.app_mode`, which can be `managed` or `self_hosted`.
|
||||
|
||||
- "Managed" - in managed mode, the Maybe team operates and manages servers for users
|
||||
- "Self Hosted" - in self hosted mode, users host the Maybe app on their own infrastructure, typically through Docker Compose. We have an example [docker-compose.example.yml](mdc:docker-compose.example.yml) file that runs [Dockerfile](mdc:Dockerfile) for this mode.
|
||||
|
||||
## Families and Users
|
||||
|
||||
- `Family` - all Stripe subscriptions, financial accounts, and the majority of preferences are stored at the [family.rb](mdc:app/models/family.rb) level.
|
||||
- `User` - all [session.rb](mdc:app/models/session.rb) happen at the [user.rb](mdc:app/models/user.rb) level. A user belongs to a `Family` and can either be an `admin` or a `member`. Typically, a `Family` has a single admin, or "head of household" that manages finances while there will be several `member` users who can see the family's finances from varying perspectives.
|
||||
|
||||
## Currency Preference
|
||||
|
||||
Each `Family` selects a currency preference. This becomes the "main" currency in which all records are "normalized" to via [exchange_rate.rb](mdc:app/models/exchange_rate.rb) records so that the Maybe app can calculate metrics, historical graphs, and other insights in a single family currency.
|
||||
|
||||
## Accounts
|
||||
|
||||
The center of the app's domain is the [account.rb](mdc:app/models/account.rb). This represents a single financial account that has a `balance` and `currency`. For example, an `Account` could be "Chase Checking", which is a single financial account at Chase Bank. A user could have multiple accounts at a single institution (i.e. "Chase Checking", "Chase Credit Card", "Chase Savings") or an account could be a standalone account, such as "My Home" (a primary residence).
|
||||
|
||||
### Accountables
|
||||
|
||||
In the app, [account.rb](mdc:app/models/account.rb) is a Rails "delegated type" with the following subtypes (separate DB tables). Each account has a `classification` or either `asset` or `liability`. While the types are a flat hierarchy, below, they have been organized by their classification:
|
||||
|
||||
- Asset accountables
|
||||
- [depository.rb](mdc:app/models/depository.rb) - a typical "bank account" such as a savings or checking account
|
||||
- [investment.rb](mdc:app/models/investment.rb) - an account that has "holdings" such as a brokerage, 401k, etc.
|
||||
- [crypto.rb](mdc:app/models/crypto.rb) - an account that tracks the value of one or more crypto holdings
|
||||
- [property.rb](mdc:app/models/property.rb) - an account that tracks the value of a physical property such as a house or rental property
|
||||
- [vehicle.rb](mdc:app/models/vehicle.rb) - an account that tracks the value of a vehicle
|
||||
- [other_asset.rb](mdc:app/models/other_asset.rb) - an asset that cannot be classified by the other account types. For example, "jewelry".
|
||||
- Liability accountables
|
||||
- [credit_card.rb](mdc:app/models/credit_card.rb) - an account that tracks the debt owed on a credit card
|
||||
- [loan.rb](mdc:app/models/loan.rb) - an account that tracks the debt owed on a loan (i.e. mortgage, student loan)
|
||||
- [other_liability.rb](mdc:app/models/other_liability.rb) - a liability that cannot be classified by the other account types. For example, "IOU to a friend"
|
||||
|
||||
### Account Balances
|
||||
|
||||
An account [balance.rb](mdc:app/models/account/balance.rb) represents a single balance value for an account on a specific `date`. A series of balance records is generated daily for each account and is how we show a user's historical balance graph.
|
||||
|
||||
- For simple accounts like a "Checking Account", the balance represents the amount of cash in the account for a date.
|
||||
- For a more complex account like "Investment Brokerage", the `balance` represents the combination of the "cash balance" + "holdings value". Each accountable type has different components that make up the "balance", but in all cases, the "balance" represents "How much the account is worth" (when `classification` is `asset`) or "How much is owed on the account" (when `classification` is `liability`)
|
||||
|
||||
All balances are calculated daily by [balance_calculator.rb](mdc:app/models/account/balance_calculator.rb).
|
||||
|
||||
### Account Holdings
|
||||
|
||||
An account [holding.rb](mdc:app/models/account/holding.rb) applies to [investment.rb](mdc:app/models/investment.rb) type accounts and represents a `qty` of a certain [security.rb](mdc:app/models/security.rb) at a specific `price` on a specific `date`.
|
||||
|
||||
For investment accounts with holdings, [holding_calculator.rb](mdc:app/models/account/holding_calculator.rb) is used to calculate the daily historical holding quantities and prices, which are then rolled up into a final "Balance" for the account in [balance_calculator.rb](mdc:app/models/account/balance_calculator.rb).
|
||||
|
||||
### Account Entries
|
||||
|
||||
An account [entry.rb](mdc:app/models/account/entry.rb) is also a Rails "delegated type". `Account::Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/account/holding.rb). Therefore, every entry must have a `date`, `amount`, and `currency`.
|
||||
|
||||
The `amount` of an [entry.rb](mdc:app/models/account/entry.rb) is a signed value. A _negative_ amount is an "inflow" of money to that account. A _positive_ value is an "outflow" of money from that account. For example:
|
||||
|
||||
- A negative amount for a credit card account represents a "payment" to that account, which _reduces_ its balance (since it is a `liability`)
|
||||
- A negative amount for a checking account represents an "income" to that account, which _increases_ its balance (since it is an `asset`)
|
||||
- A negative amount for an investment/brokerage trade represents a "sell" transaction, which _increases_ the cash balance of the account
|
||||
|
||||
There are 3 entry types, defined as [entryable.rb](mdc:app/models/account/entryable.rb) records:
|
||||
|
||||
- `Account::Valuation` - an account [valuation.rb](mdc:app/models/account/valuation.rb) is an entry that says, "here is the value of this account on this date". It is an absolute measure of an account value / debt. If there is an `Account::Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today.
|
||||
- `Account::Transaction` - an account [transaction.rb](mdc:app/models/account/transaction.rb) is an entry that alters the account balance by the `amount`. This is the most common type of entry and can be thought of as an "income" or "expense".
|
||||
- `Account::Trade` - an account [trade.rb](mdc:app/models/account/trade.rb) is an entry that only applies to an investment account. This represents a "buy" or "sell" of a holding and has a `qty` and `price`.
|
||||
|
||||
### Account Transfers
|
||||
|
||||
A [transfer.rb](mdc:app/models/transfer.rb) represents a movement of money between two accounts. A transfer has an inflow [transaction.rb](mdc:app/models/account/transaction.rb) and an outflow [transaction.rb](mdc:app/models/account/transaction.rb). The Maybe system auto-matches transfers based on the following criteria:
|
||||
|
||||
- Must be from different accounts
|
||||
- Must be within 4 days of each other
|
||||
- Must be the same currency
|
||||
- Must be opposite values
|
||||
|
||||
There are two primary forms of a transfer:
|
||||
|
||||
- Regular transfer - a normal movement of money between two accounts. For example, "Transfer $500 from Checking account to Brokerage account".
|
||||
- Debt payment - a special form of transfer where the _receiver_ of funds is a [loan.rb](mdc:app/models/loan.rb) type account.
|
||||
|
||||
Regular transfers are typically _excluded_ from income and expense calculations while a debt payment is considered an "expense".
|
||||
|
||||
## Plaid Items
|
||||
|
||||
A [plaid_item.rb](mdc:app/models/plaid_item.rb) represents a "connection" maintained by our external data provider, Plaid in the "hosted" mode of the app. An "Item" has 1 or more [plaid_account.rb](mdc:app/models/plaid_account.rb) records, which are each associated 1:1 with an internal Maybe [account.rb](mdc:app/models/account.rb).
|
||||
|
||||
All relevant metadata about the item and its underlying accounts are stored on [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb), while the "normalized" data is then stored on internal Maybe domain models.
|
||||
|
||||
## "Syncs"
|
||||
|
||||
The Maybe app has the concept of a [syncable.rb](mdc:app/models/concerns/syncable.rb), which represents any model which can have its data "synced" in the background. "Syncables" include:
|
||||
|
||||
- `Account` - an account "sync" will sync account holdings, balances, and enhance transaction metadata
|
||||
- `PlaidItem` - a Plaid Item "sync" fetches data from Plaid APIs, normalizes that data, stores it on internal Maybe models, and then finally performs an "Account sync" for each of the underlying accounts created from the Plaid Item.
|
||||
- `Family` - a Family "sync" loops through the family's Plaid Items and individual Accounts and "syncs" each of them. A family is synced once per day, automatically through [auto_sync.rb](mdc:app/controllers/concerns/auto_sync.rb).
|
||||
|
||||
Each "sync" creates a [sync.rb](mdc:app/models/sync.rb) record in the database, which keeps track of the status of the sync, any errors that it encounters, and acts as an "audit table" for synced data.
|
||||
|
||||
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:
|
||||
|
||||
- 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
|
||||
|
||||
An account sync happens every time an [entry.rb](mdc:app/models/account/entry.rb) is updated.
|
||||
|
||||
### Plaid Item Syncs
|
||||
|
||||
A Plaid Item sync is an ETL (extract, transform, load) operation:
|
||||
|
||||
1. [plaid_item.rb](mdc:app/models/plaid_item.rb) fetches data from the external Plaid API
|
||||
2. [plaid_item.rb](mdc:app/models/plaid_item.rb) creates and loads this data to [plaid_account.rb](mdc:app/models/plaid_account.rb) records
|
||||
3. [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb) transform and load data to [account.rb](mdc:app/models/account.rb) and [entry.rb](mdc:app/models/account/entry.rb), the internal Maybe representations of the data.
|
||||
|
||||
### Family Syncs
|
||||
|
||||
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.
|
||||
|
||||
|
||||
13
.cursor/rules/ui-ux-design-guidelines.mdc
Normal file
13
.cursor/rules/ui-ux-design-guidelines.mdc
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: This file describes Maybe's design system and how views should be styled
|
||||
globs: app/views/**,app/helpers/**,app/javascript/controllers/**
|
||||
---
|
||||
Use this rule whenever you are writing html, css, or even styles in Stimulus controllers that use D3.js.
|
||||
|
||||
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
|
||||
- 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`.
|
||||
@@ -1,4 +1,4 @@
|
||||
ARG RUBY_VERSION=3.3.5
|
||||
ARG RUBY_VERSION=3.4.1
|
||||
FROM ruby:${RUBY_VERSION}-slim-bullseye
|
||||
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
|
||||
12
.env.example
12
.env.example
@@ -110,4 +110,14 @@ GITHUB_REPO_BRANCH=main
|
||||
#
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# ======================================================================================================
|
||||
# Plaid Configuration
|
||||
# ======================================================================================================
|
||||
#
|
||||
PLAID_CLIENT_ID=
|
||||
PLAID_SECRET=
|
||||
PLAID_ENV=
|
||||
PLAID_EU_CLIENT_ID=
|
||||
PLAID_EU_SECRET=
|
||||
|
||||
20
.github/ISSUE_TEMPLATE/bug_report.md
vendored
20
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,12 +1,19 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: 'Bug: '
|
||||
labels: ":bug: Bug"
|
||||
title: 'Bug: [Add descriptive title here]'
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Where did this bug occur? (required)**
|
||||
|
||||
- [ ] I am a self-hosted user reporting a bug from my self hosted app
|
||||
- [ ] I am a user of Maybe's paid app
|
||||
|
||||
_Please note, if you are reporting a bug with sensitive data, please open an Intercom chat from within the app for help_
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
@@ -20,14 +27,5 @@ Steps to reproduce the behavior:
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**What version of Maybe are you using?**
|
||||
This could be "Hosted" (i.e. app.maybe.co) or "Self-hosted". If "Self-hosted", please include the version you're currently on.
|
||||
|
||||
**What operating system and browser are you using?**
|
||||
The more info the better.
|
||||
|
||||
**Screenshots / Recordings**
|
||||
If applicable, add screenshots or short video recordings to help show the bug in more detail.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@@ -22,6 +22,8 @@ jobs:
|
||||
name: Build docker image
|
||||
needs: [ ci ]
|
||||
|
||||
timeout-minutes: 60
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
@@ -65,7 +67,7 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
platforms: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64,linux/arm/v7' || 'linux/amd64,linux/arm64' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
|
||||
# Ignore bundler config.
|
||||
/.bundle
|
||||
/vendor/bundle
|
||||
|
||||
# Ignore all environment files (except templates).
|
||||
/.env*
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.3.5
|
||||
3.4.1
|
||||
|
||||
@@ -4,6 +4,8 @@ It means so much that you're interested in contributing to Maybe! Seriously. Tha
|
||||
|
||||
## House Rules
|
||||
|
||||
- Before contributing, familiarize yourself with our project conventions. You should read through our [Project Conventions Rule](https://github.com/maybe-finance/maybe/.cursor/rules/project-conventions.mdc), which is intended for LLMs, but is also an excellent primer on how we write code for Maybe.
|
||||
- While totally optional, consider using Cursor + VSCode as it will automatically apply our project conventions to your code via the `.cursor/rules` directory.
|
||||
- Before contributing, please check if it already exists in [issues](https://github.com/maybe-finance/maybe/issues) or [PRs](https://github.com/maybe-finance/maybe/pulls)
|
||||
- Given the speed at which we're moving on the codebase, we don't assign issues or "give" issues to anyone.
|
||||
- When multiple PRs are submitted for the same issue, we take the one that most succinctly & efficiently solves a given problem and stays within the scope of work.
|
||||
|
||||
22
Dockerfile
22
Dockerfile
@@ -1,15 +1,15 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
|
||||
ARG RUBY_VERSION=3.3.5
|
||||
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
|
||||
ARG RUBY_VERSION=3.4.1
|
||||
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
|
||||
|
||||
# Rails app lives here
|
||||
WORKDIR /rails
|
||||
|
||||
# Install base packages
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y curl libvips postgresql-client
|
||||
apt-get install --no-install-recommends -y curl libvips postgresql-client git
|
||||
|
||||
# Set production environment
|
||||
ENV RAILS_ENV="production" \
|
||||
@@ -17,29 +17,29 @@ ENV RAILS_ENV="production" \
|
||||
BUNDLE_PATH="/usr/local/bundle" \
|
||||
BUNDLE_WITHOUT="development"
|
||||
|
||||
|
||||
# Throw-away build stage to reduce size of final image
|
||||
FROM base as build
|
||||
FROM base AS build
|
||||
|
||||
# Install packages needed to build gems
|
||||
RUN apt-get install --no-install-recommends -y build-essential git libpq-dev pkg-config
|
||||
RUN apt-get install --no-install-recommends -y build-essential libpq-dev pkg-config
|
||||
|
||||
# Install application gems
|
||||
COPY .ruby-version Gemfile Gemfile.lock ./
|
||||
RUN bundle install && \
|
||||
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
|
||||
bundle exec bootsnap precompile --gemfile
|
||||
RUN bundle install
|
||||
|
||||
RUN rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git
|
||||
|
||||
RUN bundle exec bootsnap precompile --gemfile -j 0
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Precompile bootsnap code for faster boot times
|
||||
RUN bundle exec bootsnap precompile app/ lib/
|
||||
RUN bundle exec bootsnap precompile -j 0 app/ lib/
|
||||
|
||||
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
|
||||
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
|
||||
|
||||
|
||||
# Final stage for app image
|
||||
FROM base
|
||||
|
||||
|
||||
14
Gemfile
14
Gemfile
@@ -21,22 +21,27 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails"
|
||||
# Hotwire
|
||||
gem "stimulus-rails"
|
||||
gem "turbo-rails"
|
||||
gem "hotwire_combobox"
|
||||
|
||||
# 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"
|
||||
|
||||
# Background Jobs
|
||||
gem "good_job"
|
||||
|
||||
# Error logging
|
||||
gem "stackprof"
|
||||
gem "rack-mini-profiler"
|
||||
gem "sentry-ruby"
|
||||
gem "sentry-rails"
|
||||
gem "logtail-rails"
|
||||
|
||||
# Active Storage
|
||||
gem "aws-sdk-s3", require: false
|
||||
gem "aws-sdk-s3", "~> 1.177.0", require: false
|
||||
gem "image_processing", ">= 1.2"
|
||||
|
||||
# Other
|
||||
gem "bcrypt", "~> 3.1"
|
||||
gem "jwt"
|
||||
gem "faraday"
|
||||
gem "faraday-retry"
|
||||
gem "faraday-multipart"
|
||||
@@ -49,7 +54,9 @@ gem "csv"
|
||||
gem "redcarpet"
|
||||
gem "stripe"
|
||||
gem "intercom-rails"
|
||||
gem "holidays"
|
||||
gem "plaid"
|
||||
gem "rotp", "~> 6.3"
|
||||
gem "rqrcode", "~> 2.2"
|
||||
|
||||
group :development, :test do
|
||||
gem "debug", platforms: %i[mri windows]
|
||||
@@ -66,6 +73,7 @@ group :development do
|
||||
gem "ruby-lsp-rails"
|
||||
gem "web-console"
|
||||
gem "faker"
|
||||
gem "benchmark-ips"
|
||||
end
|
||||
|
||||
group :test do
|
||||
|
||||
437
Gemfile.lock
437
Gemfile.lock
@@ -1,3 +1,14 @@
|
||||
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
|
||||
@@ -8,29 +19,29 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actioncable (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionmailbox (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activestorage (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionmailer (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
actionview (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionpack (7.2.2.1)
|
||||
actionview (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4, < 3.2)
|
||||
@@ -39,35 +50,35 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actiontext (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activestorage (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionview (7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activejob (7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activerecord (7.2.2)
|
||||
activemodel (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activemodel (7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
activerecord (7.2.2.1)
|
||||
activemodel (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activestorage (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.2.2)
|
||||
activesupport (7.2.2.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
@@ -83,24 +94,25 @@ GEM
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.992.0)
|
||||
aws-sdk-core (3.210.0)
|
||||
aws-partitions (1.1043.0)
|
||||
aws-sdk-core (3.217.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.95.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-kms (1.97.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.169.0)
|
||||
aws-sdk-s3 (1.177.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.10.0)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.3.0)
|
||||
benchmark (0.4.0)
|
||||
benchmark-ips (2.14.0)
|
||||
better_html (2.1.1)
|
||||
actionview (>= 6.0)
|
||||
activesupport (>= 6.0)
|
||||
@@ -108,11 +120,11 @@ GEM
|
||||
erubi (~> 1.4)
|
||||
parser (>= 2.4)
|
||||
smart_properties
|
||||
bigdecimal (3.1.8)
|
||||
bigdecimal (3.1.9)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (6.2.2)
|
||||
brakeman (7.0.0)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
@@ -124,77 +136,77 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
childprocess (5.0.0)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
climate_control (1.2.0)
|
||||
concurrent-ruby (1.3.4)
|
||||
connection_pool (2.4.1)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.0)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
csv (3.3.0)
|
||||
date (3.4.0)
|
||||
debug (1.9.2)
|
||||
csv (3.3.2)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
docile (1.4.0)
|
||||
dotenv (3.1.4)
|
||||
dotenv-rails (3.1.4)
|
||||
dotenv (= 3.1.4)
|
||||
docile (1.4.1)
|
||||
dotenv (3.1.7)
|
||||
dotenv-rails (3.1.7)
|
||||
dotenv (= 3.1.7)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.1)
|
||||
erb_lint (0.7.0)
|
||||
erb_lint (0.9.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
parser (>= 2.7.1.4)
|
||||
rainbow
|
||||
rubocop (>= 1)
|
||||
smart_properties
|
||||
erubi (1.13.0)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.12.0)
|
||||
faraday-net_http (>= 2.0, < 3.4)
|
||||
faraday (2.12.2)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (3.3.0)
|
||||
net-http
|
||||
faraday-multipart (1.1.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-retry (2.2.1)
|
||||
faraday (~> 2.0)
|
||||
ffi (1.17.0-aarch64-linux-gnu)
|
||||
ffi (1.17.0-arm-linux-gnu)
|
||||
ffi (1.17.0-arm64-darwin)
|
||||
ffi (1.17.0-x86-linux-gnu)
|
||||
ffi (1.17.0-x86_64-darwin)
|
||||
ffi (1.17.0-x86_64-linux-gnu)
|
||||
ffi (1.17.1-aarch64-linux-gnu)
|
||||
ffi (1.17.1-aarch64-linux-musl)
|
||||
ffi (1.17.1-arm-linux-gnu)
|
||||
ffi (1.17.1-arm-linux-musl)
|
||||
ffi (1.17.1-arm64-darwin)
|
||||
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.4.2)
|
||||
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.1)
|
||||
highline (3.0.1)
|
||||
holidays (8.8.0)
|
||||
hotwire-livereload (1.4.1)
|
||||
actioncable (>= 6.0.0)
|
||||
hashdiff (1.1.2)
|
||||
highline (3.1.2)
|
||||
reline
|
||||
hotwire-livereload (2.0.0)
|
||||
actioncable (>= 7.0.0)
|
||||
listen (>= 3.0.0)
|
||||
railties (>= 6.0.0)
|
||||
hotwire_combobox (0.3.2)
|
||||
rails (>= 7.0.7.2)
|
||||
stimulus-rails (>= 1.2)
|
||||
turbo-rails (>= 1.2)
|
||||
i18n (1.14.6)
|
||||
railties (>= 7.0.0)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-tasks (1.0.14)
|
||||
activesupport (>= 4.0.2)
|
||||
@@ -206,35 +218,51 @@ GEM
|
||||
rails-i18n
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
terminal-table (>= 1.5.1)
|
||||
image_processing (1.13.0)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
image_processing (1.14.0)
|
||||
mini_magick (>= 4.9.5, < 6)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
importmap-rails (2.0.3)
|
||||
importmap-rails (2.1.0)
|
||||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
inline_svg (1.10.0)
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
intercom-rails (1.0.1)
|
||||
intercom-rails (1.0.6)
|
||||
activesupport (> 4.0)
|
||||
io-console (0.7.2)
|
||||
irb (1.14.1)
|
||||
jwt (~> 2.0)
|
||||
io-console (0.8.0)
|
||||
irb (1.15.1)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.7.2)
|
||||
language_server-protocol (3.17.0.3)
|
||||
launchy (3.0.1)
|
||||
json (2.10.1)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
language_server-protocol (3.17.0.4)
|
||||
launchy (3.1.0)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.6.1)
|
||||
loofah (2.23.1)
|
||||
logger (1.6.6)
|
||||
logtail (0.1.15)
|
||||
msgpack (~> 1.0)
|
||||
logtail-rack (0.2.6)
|
||||
logtail (~> 0.1)
|
||||
rack (>= 1.2, < 4.0)
|
||||
logtail-rails (0.2.10)
|
||||
actionpack (>= 5.0.0)
|
||||
activerecord (>= 5.0.0)
|
||||
logtail (~> 0.1, >= 0.1.14)
|
||||
logtail-rack (~> 0.1)
|
||||
railties (>= 5.0.0)
|
||||
loofah (2.24.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -244,16 +272,18 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
mini_magick (4.13.2)
|
||||
mini_magick (5.1.2)
|
||||
benchmark
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.1)
|
||||
mocha (2.5.0)
|
||||
minitest (5.25.4)
|
||||
mocha (2.7.1)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
msgpack (1.8.0)
|
||||
multipart-post (2.4.1)
|
||||
net-http (0.4.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.0)
|
||||
net-imap (0.5.5)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -263,77 +293,94 @@ GEM
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.16.7-aarch64-linux)
|
||||
nokogiri (1.18.2-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-arm-linux)
|
||||
nokogiri (1.18.2-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-arm64-darwin)
|
||||
nokogiri (1.18.2-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-x86-linux)
|
||||
nokogiri (1.18.2-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-x86_64-darwin)
|
||||
nokogiri (1.18.2-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.7-x86_64-linux)
|
||||
nokogiri (1.18.2-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
octokit (9.2.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (9.1.1)
|
||||
pagy (9.3.3)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.5.0)
|
||||
parser (3.3.7.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
prism (1.2.0)
|
||||
plaid (36.0.0)
|
||||
faraday (>= 1.0.1, < 3.0)
|
||||
faraday-multipart (>= 1.0.1, < 2.0)
|
||||
platform_agent (1.0.1)
|
||||
activesupport (>= 5.2.0)
|
||||
useragent (~> 0.16.3)
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.3.0)
|
||||
propshaft (1.1.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
railties (>= 7.0.0)
|
||||
psych (5.1.2)
|
||||
psych (5.2.3)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.4.3)
|
||||
puma (6.6.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.8)
|
||||
rack-session (2.0.0)
|
||||
rack (3.1.10)
|
||||
rack-mini-profiler (3.3.1)
|
||||
rack (>= 1.2.0)
|
||||
rack-session (2.1.0)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.0)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (7.2.2)
|
||||
actioncable (= 7.2.2)
|
||||
actionmailbox (= 7.2.2)
|
||||
actionmailer (= 7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
actiontext (= 7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activemodel (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
rails (7.2.2.1)
|
||||
actioncable (= 7.2.2.1)
|
||||
actionmailbox (= 7.2.2.1)
|
||||
actionmailer (= 7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
actiontext (= 7.2.2.1)
|
||||
actionview (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activemodel (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activestorage (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.2)
|
||||
railties (= 7.2.2.1)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.0)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
rails-i18n (7.0.9)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (7.0.10)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 6.0.0, < 8)
|
||||
rails-settings-cached (2.9.5)
|
||||
rails-settings-cached (2.9.6)
|
||||
activerecord (>= 5.0.0)
|
||||
railties (>= 5.0.0)
|
||||
railties (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
railties (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -344,106 +391,114 @@ GEM
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (3.6.1)
|
||||
rbs (3.8.1)
|
||||
logger
|
||||
rdoc (6.7.0)
|
||||
rdoc (6.12.0)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.0)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.10)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.3.9)
|
||||
rubocop (1.67.0)
|
||||
rexml (3.4.0)
|
||||
rotp (6.3.0)
|
||||
rqrcode (2.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 1.0)
|
||||
rqrcode_core (1.2.0)
|
||||
rubocop (1.71.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.4, < 3.0)
|
||||
rubocop-ast (>= 1.32.2, < 2.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.36.2, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.32.3)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.38.0)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-minitest (0.35.0)
|
||||
rubocop-minitest (0.36.0)
|
||||
rubocop (>= 1.61, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-performance (1.21.0)
|
||||
rubocop-performance (1.23.1)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails (2.25.0)
|
||||
rubocop-rails (2.29.1)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.33.0, < 2.0)
|
||||
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.20.1)
|
||||
ruby-lsp (0.23.9)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 1.2, < 2.0)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.3.21)
|
||||
ruby-lsp (>= 0.20.0, < 0.21.0)
|
||||
ruby-lsp-rails (0.4.0)
|
||||
ruby-lsp (>= 0.23.0, < 0.24.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.2)
|
||||
ruby-vips (2.2.3)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
rubyzip (2.4.1)
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
securerandom (0.3.1)
|
||||
selenium-webdriver (4.26.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.28.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.21.0)
|
||||
sentry-rails (5.22.4)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.21.0)
|
||||
sentry-ruby (5.21.0)
|
||||
sentry-ruby (~> 5.22.4)
|
||||
sentry-ruby (5.22.4)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.12.3)
|
||||
simplecov-html (0.13.1)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11618)
|
||||
stackprof (0.2.26)
|
||||
sorbet-runtime (0.5.11813)
|
||||
stackprof (0.2.27)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.1)
|
||||
stripe (13.1.0)
|
||||
tailwindcss-rails (3.0.0)
|
||||
stringio (3.1.3)
|
||||
stripe (13.4.1)
|
||||
tailwindcss-rails (4.0.0)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby
|
||||
tailwindcss-ruby (3.4.14)
|
||||
tailwindcss-ruby (3.4.14-aarch64-linux)
|
||||
tailwindcss-ruby (3.4.14-arm-linux)
|
||||
tailwindcss-ruby (3.4.14-arm64-darwin)
|
||||
tailwindcss-ruby (3.4.14-x86_64-darwin)
|
||||
tailwindcss-ruby (3.4.14-x86_64-linux)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
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)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
thor (1.3.2)
|
||||
timeout (0.4.1)
|
||||
timeout (0.4.3)
|
||||
turbo-rails (2.0.11)
|
||||
actionpack (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (2.6.0)
|
||||
uri (0.13.1)
|
||||
useragent (0.16.10)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.2)
|
||||
useragent (0.16.11)
|
||||
vcr (6.3.1)
|
||||
base64
|
||||
web-console (4.2.1)
|
||||
@@ -451,12 +506,13 @@ GEM
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webmock (3.24.0)
|
||||
webmock (3.25.0)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-driver (0.7.7)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
@@ -465,15 +521,21 @@ GEM
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
aarch64-linux-gnu
|
||||
aarch64-linux-musl
|
||||
arm-linux
|
||||
arm-linux-gnu
|
||||
arm-linux-musl
|
||||
arm64-darwin
|
||||
x86-linux
|
||||
x86_64-darwin
|
||||
x86_64-linux
|
||||
x86_64-linux-gnu
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
aws-sdk-s3
|
||||
aws-sdk-s3 (~> 1.177.0)
|
||||
bcrypt (~> 3.1)
|
||||
benchmark-ips
|
||||
bootsnap
|
||||
brakeman
|
||||
capybara
|
||||
@@ -487,25 +549,30 @@ DEPENDENCIES
|
||||
faraday-multipart
|
||||
faraday-retry
|
||||
good_job
|
||||
holidays
|
||||
hotwire-livereload
|
||||
hotwire_combobox
|
||||
hotwire_combobox!
|
||||
i18n-tasks
|
||||
image_processing (>= 1.2)
|
||||
importmap-rails
|
||||
inline_svg
|
||||
intercom-rails
|
||||
jwt
|
||||
letter_opener
|
||||
logtail-rails
|
||||
lucide-rails!
|
||||
mocha
|
||||
octokit
|
||||
pagy
|
||||
pg (~> 1.5)
|
||||
plaid
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
rack-mini-profiler
|
||||
rails (~> 7.2.2)
|
||||
rails-settings-cached
|
||||
redcarpet
|
||||
rotp (~> 6.3)
|
||||
rqrcode (~> 2.2)
|
||||
rubocop-rails-omakase
|
||||
ruby-lsp-rails
|
||||
selenium-webdriver
|
||||
@@ -523,7 +590,7 @@ DEPENDENCIES
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.5p100
|
||||
ruby 3.4.1p0
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.22
|
||||
2.6.3
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
web: ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
|
||||
css: bin/rails tailwindcss:watch
|
||||
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
|
||||
|
||||
20
README.md
20
README.md
@@ -4,7 +4,7 @@
|
||||
# Maybe: The OS for your personal finances
|
||||
|
||||
<b>Get
|
||||
involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybe.co) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
|
||||
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)._
|
||||
@@ -33,6 +33,15 @@ There are 3 primary ways to use the Maybe app:
|
||||
2. [One-click deploy](docs/hosting/one-click-deploy.md)
|
||||
3. [Self-host with Docker](docs/hosting/docker.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
Before contributing, you'll likely find it helpful
|
||||
to [understand context and general vision/direction](https://github.com/maybe-finance/maybe/wiki).
|
||||
|
||||
Once you've done that, please visit
|
||||
our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md)
|
||||
to get started!
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
**If you are trying to _self-host_ the Maybe app, stop here. You
|
||||
@@ -107,15 +116,6 @@ 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.
|
||||
|
||||
## Contributing
|
||||
|
||||
Before contributing, you'll likely find it helpful
|
||||
to [understand context and general vision/direction](https://github.com/maybe-finance/maybe/wiki).
|
||||
|
||||
Once you've done that, please visit
|
||||
our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md)
|
||||
to get started!
|
||||
|
||||
## Repo Activity
|
||||
|
||||

|
||||
|
||||
BIN
app/assets/fonts/geist/Geist-Black.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-Black.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-Bold.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-Bold.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-ExtraBold.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-ExtraBold.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-ExtraLight.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-ExtraLight.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-Light.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-Light.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-Medium.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-Medium.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-Regular.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-Regular.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-SemiBold.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-SemiBold.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-Thin.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-Thin.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist[wght].woff2
Normal file
BIN
app/assets/fonts/geist/Geist[wght].woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-Black.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-Black.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-Bold.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-Bold.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-Light.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-Light.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-Medium.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-Medium.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-Regular.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-Regular.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-SemiBold.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-SemiBold.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-Thin.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-Thin.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-UltraBlack.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-UltraBlack.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-UltraLight.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-UltraLight.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono[wght].woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono[wght].woff2
Normal file
Binary file not shown.
12
app/assets/images/logomark-color.svg
Normal file
12
app/assets/images/logomark-color.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none">
|
||||
<path d="M8.39804 24.0315H4.09584C3.07641 24.0315 2.25 24.8609 2.25 25.8841C2.25 26.9072 3.07641 27.7367 4.09584 27.7367H8.39804C9.41747 27.7367 10.2439 26.9072 10.2439 25.8841C10.2439 24.8609 9.41747 24.0315 8.39804 24.0315Z" fill="#F23E94"/>
|
||||
<path d="M27.6403 27.7359H31.9425C32.9619 27.7359 33.7883 26.9065 33.7883 25.8833C33.7883 24.8601 32.9619 24.0307 31.9425 24.0307H27.6403C26.6209 24.0307 25.7945 24.8601 25.7945 25.8833C25.7945 26.9065 26.6209 27.7359 27.6403 27.7359Z" fill="#F23E94"/>
|
||||
<path d="M19.7588 24.0189H16.2567C15.2373 24.0189 14.4109 24.8483 14.4109 25.8715C14.4109 26.8947 15.2373 27.7241 16.2567 27.7241H19.7588C20.7783 27.7241 21.6047 26.8947 21.6047 25.8715C21.6047 24.8483 20.7783 24.0189 19.7588 24.0189Z" fill="#F23E94"/>
|
||||
<path d="M25.9683 22.4047H30.1112C31.1306 22.4047 31.957 21.5753 31.957 20.5521C31.957 19.529 31.1306 18.6995 30.1112 18.6995H25.9683C24.9489 18.6995 24.1225 19.529 24.1225 20.5521C24.1225 21.5753 24.9489 22.4047 25.9683 22.4047Z" fill="#6927DA"/>
|
||||
<path d="M9.99971 18.6993H5.85685C4.83742 18.6993 4.01101 19.5288 4.01101 20.5519C4.01101 21.5751 4.83742 22.4045 5.85685 22.4045H9.99971C11.0191 22.4045 11.8455 21.5751 11.8455 20.5519C11.8455 19.5288 11.0191 18.6993 9.99971 18.6993Z" fill="#6927DA"/>
|
||||
<path d="M21.0888 18.6875H14.924C13.9045 18.6875 13.0781 19.517 13.0781 20.5401C13.0781 21.5633 13.9045 22.3927 14.924 22.3927H21.0888C22.1082 22.3927 22.9346 21.5633 22.9346 20.5401C22.9346 19.517 22.1082 18.6875 21.0888 18.6875Z" fill="#6927DA"/>
|
||||
<path d="M15.5578 13.2072H7.69136C6.67193 13.2072 5.84552 14.0366 5.84552 15.0598C5.84552 16.0829 6.67193 16.9123 7.69136 16.9123H15.5578C16.5772 16.9123 17.4036 16.0829 17.4036 15.0598C17.4036 14.0366 16.5772 13.2072 15.5578 13.2072Z" fill="#1570EF"/>
|
||||
<path d="M20.9094 16.9116H28.2735C29.2929 16.9116 30.1193 16.0821 30.1193 15.059C30.1193 14.0358 29.2929 13.2064 28.2735 13.2064L20.9094 13.2064C19.89 13.2064 19.0636 14.0358 19.0636 15.059C19.0636 16.0821 19.89 16.9116 20.9094 16.9116Z" fill="#1570EF"/>
|
||||
<path d="M26.5036 7.875H22.3515C21.3321 7.875 20.5057 8.70443 20.5057 9.72759C20.5057 10.7507 21.3321 11.5802 22.3515 11.5802H26.5036C27.523 11.5802 28.3494 10.7507 28.3494 9.72759C28.3494 8.70443 27.523 7.875 26.5036 7.875Z" fill="#22CCEE"/>
|
||||
<path d="M13.6077 7.875H9.45557C8.43614 7.875 7.60973 8.70443 7.60973 9.72759C7.60973 10.7507 8.43614 11.5802 9.45557 11.5802H13.6077C14.6271 11.5802 15.4535 10.7507 15.4535 9.72759C15.4535 8.70443 14.6271 7.875 13.6077 7.875Z" fill="#22CCEE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
10
app/assets/images/placeholder-graph.svg
Normal file
10
app/assets/images/placeholder-graph.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="944" height="201" viewBox="0 0 944 201" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 56.5502L14.4845 52.101L28.9689 50.1276L43.4534 51.7926L57.9379 40.2042L72.4224 35.6995L86.9068 35.0612L101.391 51.2218L115.876 73.6398L130.36 65.7562L144.845 64.7572L159.329 78.5795L173.814 81.9833L188.298 71.3186L202.783 80.5112L217.267 86L231.752 84.5697L246.236 83.0772L260.721 78.4002L275.205 77.343L289.689 71.8152L304.174 52.25L318.658 51.5349L333.143 48.185L347.627 47.2522L362.112 45.4586L376.596 49.2356L391.081 47.5566L405.565 31.0549L420.05 28.5641L434.534 36.6352H449.019L463.503 42.7572L477.988 37.7564L492.472 42.3467L506.957 49.3852L521.441 59.4839L535.925 52.7514L550.41 47.1535L564.894 58.6703L579.379 49.8343L593.863 50.5123H608.348L622.832 54.192L637.317 58.4763L651.801 57.2522L666.286 59.3943L677.01 62.8533L688.553 59.3943L709.129 67.4827L724.224 60.8386L738.708 52.27L753.193 58.6965L767.677 37.887L782.162 28.3178L796.646 16.383L811.13 20.9733L825.615 10.2626L840.099 11.7927L854.584 6.59032L869.068 15.771L883.553 8.12043L898.037 6.59032L912.522 2L927.006 14.8529L944 15.771" stroke="#0B0B0B" stroke-opacity="0.25" stroke-width="2" stroke-miterlimit="16"/>
|
||||
<path d="M14.4845 52.5538L0 57.0432V201H944V15.8954L927.006 14.9691L912.522 2L898.037 6.63181L883.553 8.17575L869.068 15.8954L854.584 6.63181L840.099 11.8812L825.615 10.3373L811.13 21.1448L796.646 16.513L782.161 28.5557L767.677 38.2114L753.193 59.2089L738.708 52.7244L724.224 61.3704L709.129 68.0745L688.553 59.9131L677.01 63.4034L666.286 59.9131L651.801 57.7516L637.317 58.9868L622.832 54.6637L608.348 50.9508H593.863L579.379 50.2667L564.894 59.1826L550.41 47.5616L535.925 53.2102L521.441 60.0035L506.957 49.8135L492.472 42.7114L477.988 38.0796L463.503 43.1256L449.019 36.9483H434.534L420.05 28.8042L405.565 31.3175L391.081 47.9684L376.596 49.6626L362.112 45.8514L347.627 47.6612L333.143 48.6024L318.658 51.9826L304.174 52.7042L289.689 72.4463L275.205 78.024L260.721 79.0908L246.236 83.8101L231.752 85.3161L217.267 86.7593L202.783 81.2209L188.298 71.9451L173.814 82.7063L159.329 79.2716L144.845 65.3245L130.36 66.3325L115.876 74.2874L101.391 51.6667L86.9068 35.3601L72.4224 36.0041L57.9379 40.5496L43.4534 52.2427L28.9689 50.5627L14.4845 52.5538Z" fill="url(#paint0_linear_4023_1299)" fill-opacity="0.5"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_4023_1299" x1="445.5" y1="174.496" x2="445.5" y2="51.9672" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E5E5E5" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -1,165 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Reset rules, default styles applied to plain HTML */
|
||||
@layer base {
|
||||
details>summary::-webkit-details-marker {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
details>summary {
|
||||
@apply list-none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
.form-field__label, .hw-combobox__label {
|
||||
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.form-field__input {
|
||||
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
|
||||
@apply focus:opacity-100 focus:outline-none focus:ring-0;
|
||||
@apply placeholder-shown:opacity-50;
|
||||
@apply disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.form-field__radio {
|
||||
@apply text-gray-900;
|
||||
}
|
||||
|
||||
.form-field__submit {
|
||||
@apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
||||
}
|
||||
|
||||
input:checked+label+.toggle-switch-dot {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox {
|
||||
@apply rounded-sm;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--light {
|
||||
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark {
|
||||
@apply ring-gray-900 checked:text-white;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark:checked {
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
select[multiple="multiple"] {
|
||||
@apply py-2 pr-2 space-y-0.5;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.maybe-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 peer-checked:bg-green-600 peer-checked:after:translate-x-4;
|
||||
}
|
||||
|
||||
.prose--github-release-notes {
|
||||
.octicon {
|
||||
@apply inline-block overflow-visible align-text-bottom fill-current;
|
||||
}
|
||||
|
||||
.dropdown-caret {
|
||||
@apply content-none border-4 border-b-0 border-transparent border-t-gray-500 size-0 inline-block;
|
||||
}
|
||||
|
||||
.user-mention {
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
@apply hidden absolute;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer focus:outline-gray-500;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
@apply bg-gray-50 hover:bg-gray-100 text-gray-900;
|
||||
}
|
||||
|
||||
.btn--outline {
|
||||
@apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
@apply border border-transparent text-gray-900 hover:bg-gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
.combobox {
|
||||
.hw-combobox__main__wrapper, .hw-combobox__input {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.hw-combobox__main__wrapper {
|
||||
@apply border-0 p-0 focus:border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none focus-within:shadow-none;
|
||||
}
|
||||
|
||||
.hw-combobox__listbox {
|
||||
@apply absolute top-[160%] right-0 w-full bg-transparent rounded z-30;
|
||||
}
|
||||
|
||||
.hw_combobox__pagination__wrapper {
|
||||
@apply h-px;
|
||||
|
||||
&:only-child {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
--hw-border-color: rgba(0, 0, 0, 0.2);
|
||||
--hw-handle-width: 20px;
|
||||
--hw-handle-height: 20px;
|
||||
--hw-handle-offset-right: 0px;
|
||||
}
|
||||
|
||||
/* Small, single purpose classes that should take precedence over other styles */
|
||||
@layer utilities {
|
||||
.scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #d6d6d6;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
115
app/assets/tailwind/application.css
Normal file
115
app/assets/tailwind/application.css
Normal file
@@ -0,0 +1,115 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import "./maybe-design-system.css";
|
||||
|
||||
@import "./geist-font.css";
|
||||
@import "./geist-mono-font.css";
|
||||
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
.combobox {
|
||||
.hw-combobox__main__wrapper,
|
||||
.hw-combobox__input {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.hw-combobox__main__wrapper {
|
||||
@apply border-0 p-0 focus:border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none focus-within:shadow-none;
|
||||
}
|
||||
|
||||
.hw-combobox__listbox {
|
||||
@apply absolute top-[160%] right-0 w-full bg-transparent rounded z-30;
|
||||
}
|
||||
|
||||
.hw-combobox__label {
|
||||
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.hw_combobox__pagination__wrapper {
|
||||
@apply h-px;
|
||||
|
||||
&:only-child {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
--hw-border-color: rgba(0, 0, 0, 0.2);
|
||||
--hw-handle-width: 20px;
|
||||
--hw-handle-height: 20px;
|
||||
--hw-handle-offset-right: 0px;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.prose {
|
||||
@apply max-w-none;
|
||||
|
||||
h2 {
|
||||
@apply text-xl font-medium;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-lg font-medium;
|
||||
}
|
||||
|
||||
li {
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
details {
|
||||
@apply mb-4 rounded-xl mt-3.5;
|
||||
}
|
||||
|
||||
summary {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
video {
|
||||
@apply m-0 rounded-b-xl;
|
||||
}
|
||||
}
|
||||
|
||||
.prose--github-release-notes {
|
||||
.octicon {
|
||||
@apply inline-block overflow-visible align-text-bottom fill-current;
|
||||
}
|
||||
|
||||
.dropdown-caret {
|
||||
@apply content-none border-4 border-b-0 border-transparent border-t-gray-500 size-0 inline-block;
|
||||
}
|
||||
|
||||
.user-mention {
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar implementation for Windows browsers */
|
||||
.windows {
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d6d6d6;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d6d6d6;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
85
app/assets/tailwind/geist-font.css
Normal file
85
app/assets/tailwind/geist-font.css
Normal file
@@ -0,0 +1,85 @@
|
||||
|
||||
|
||||
/* Variable font */
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist[wght].woff2') format('woff2-variations');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Static fonts (fallback) */
|
||||
@supports not (font-variation-settings: normal) {
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-Thin.woff2') format('woff2');
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-ExtraLight.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-SemiBold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-ExtraBold.woff2') format('woff2');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-Black.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
}
|
||||
83
app/assets/tailwind/geist-mono-font.css
Normal file
83
app/assets/tailwind/geist-mono-font.css
Normal file
@@ -0,0 +1,83 @@
|
||||
/* Variable font */
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono[wght].woff2') format('woff2-variations');
|
||||
font-weight: 100 950;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Static fonts (fallback) */
|
||||
@supports not (font-variation-settings: normal) {
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-Thin.woff2') format('woff2');
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-UltraLight.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-SemiBold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-Black.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-UltraBlack.woff2') format('woff2');
|
||||
font-weight: 950;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
}
|
||||
451
app/assets/tailwind/maybe-design-system.css
Normal file
451
app/assets/tailwind/maybe-design-system.css
Normal file
@@ -0,0 +1,451 @@
|
||||
/*
|
||||
This file contains all of the Figma design tokens, components, etc. that
|
||||
are used globally across the app.
|
||||
|
||||
One-off styling (3rd party overrides, etc.) should be done in the application.css file.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
/* Font families */
|
||||
--font-sans: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
|
||||
/* Base colors */
|
||||
--color-white: #ffffff;
|
||||
--color-black: #0B0B0B;
|
||||
--color-success: var(--color-green-600);
|
||||
--color-warning: var(--color-yellow-600);
|
||||
--color-destructive: var(--color-red-600);
|
||||
|
||||
/* Gray scale */
|
||||
--color-gray-25: #FAFAFA;
|
||||
--color-gray-50: #F7F7F7;
|
||||
--color-gray-100: #F0F0F0;
|
||||
--color-gray-200: #E7E7E7;
|
||||
--color-gray-300: #CFCFCF;
|
||||
--color-gray-400: #9E9E9E;
|
||||
--color-gray-500: #737373;
|
||||
--color-gray-600: #5C5C5C;
|
||||
--color-gray-700: #363636;
|
||||
--color-gray-800: #242424;
|
||||
--color-gray-900: #171717;
|
||||
--color-gray: var(--color-gray-500);
|
||||
--color-gray-tint-5: --alpha(var(--color-gray-500) / 5%);
|
||||
--color-gray-tint-10: --alpha(var(--color-gray-500) / 10%);
|
||||
|
||||
/* Alpha colors */
|
||||
--color-alpha-white-25: --alpha(var(--color-white) / 3%);
|
||||
--color-alpha-white-50: --alpha(var(--color-white) / 5%);
|
||||
--color-alpha-white-100: --alpha(var(--color-white) / 8%);
|
||||
--color-alpha-white-200: --alpha(var(--color-white) / 10%);
|
||||
--color-alpha-white-300: --alpha(var(--color-white) / 15%);
|
||||
--color-alpha-white-400: --alpha(var(--color-white) / 20%);
|
||||
--color-alpha-white-500: --alpha(var(--color-white) / 30%);
|
||||
--color-alpha-white-600: --alpha(var(--color-white) / 40%);
|
||||
--color-alpha-white-700: --alpha(var(--color-white) / 50%);
|
||||
--color-alpha-white-800: --alpha(var(--color-white) / 70%);
|
||||
--color-alpha-white-900: --alpha(var(--color-white) / 85%);
|
||||
|
||||
--color-alpha-black-25: --alpha(var(--color-black) / 3%);
|
||||
--color-alpha-black-50: --alpha(var(--color-black) / 5%);
|
||||
--color-alpha-black-100: --alpha(var(--color-black) / 8%);
|
||||
--color-alpha-black-200: --alpha(var(--color-black) / 10%);
|
||||
--color-alpha-black-300: --alpha(var(--color-black) / 15%);
|
||||
--color-alpha-black-400: --alpha(var(--color-black) / 20%);
|
||||
--color-alpha-black-500: --alpha(var(--color-black) / 30%);
|
||||
--color-alpha-black-600: --alpha(var(--color-black) / 40%);
|
||||
--color-alpha-black-700: --alpha(var(--color-black) / 50%);
|
||||
--color-alpha-black-800: --alpha(var(--color-black) / 70%);
|
||||
--color-alpha-black-900: --alpha(var(--color-black) / 85%);
|
||||
|
||||
/* Red scale */
|
||||
--color-red-25: #FFFBFB;
|
||||
--color-red-50: #FFF1F0;
|
||||
--color-red-100: #FFDEDB;
|
||||
--color-red-200: #FEB9B3;
|
||||
--color-red-300: #F88C86;
|
||||
--color-red-400: #ED4E4E;
|
||||
--color-red-500: #F13636;
|
||||
--color-red-600: #EC2222;
|
||||
--color-red-700: #C91313;
|
||||
--color-red-800: #A40E0E;
|
||||
--color-red-900: #7E0707;
|
||||
--color-red-tint-5: --alpha(var(--color-red-500) / 5%);
|
||||
--color-red-tint-10: --alpha(var(--color-red-500) / 10%);
|
||||
|
||||
/* Green scale */
|
||||
--color-green-25: #F6FEF9;
|
||||
--color-green-50: #ECFDF3;
|
||||
--color-green-100: #D1FADF;
|
||||
--color-green-200: #A6F4C5;
|
||||
--color-green-300: #6CE9A6;
|
||||
--color-green-400: #32D583;
|
||||
--color-green-500: #12B76A;
|
||||
--color-green-600: #10A861;
|
||||
--color-green-700: #078C52;
|
||||
--color-green-800: #05603A;
|
||||
--color-green-900: #054F31;
|
||||
--color-green-tint-5: --alpha(var(--color-green-500) / 5%);
|
||||
--color-green-tint-10: --alpha(var(--color-green-500) / 10%);
|
||||
|
||||
/* Yellow scale */
|
||||
--color-yellow-25: #FFFCF5;
|
||||
--color-yellow-50: #FFFAEB;
|
||||
--color-yellow-100: #FEF0C7;
|
||||
--color-yellow-200: #FEDF89;
|
||||
--color-yellow-300: #FEC84B;
|
||||
--color-yellow-400: #FDB022;
|
||||
--color-yellow-500: #F79009;
|
||||
--color-yellow-600: #DC6803;
|
||||
--color-yellow-700: #B54708;
|
||||
--color-yellow-800: #93370D;
|
||||
--color-yellow-900: #7A2E0E;
|
||||
--color-yellow-tint-5: --alpha(var(--color-yellow-500) / 5%);
|
||||
--color-yellow-tint-10: --alpha(var(--color-yellow-500) / 10%);
|
||||
|
||||
/* Cyan scale */
|
||||
--color-cyan-25: #F5FEFF;
|
||||
--color-cyan-50: #ECFDFF;
|
||||
--color-cyan-100: #CFF9FE;
|
||||
--color-cyan-200: #A5F0FC;
|
||||
--color-cyan-300: #67E3F9;
|
||||
--color-cyan-400: #22CCEE;
|
||||
--color-cyan-500: #06AED4;
|
||||
--color-cyan-600: #088AB2;
|
||||
--color-cyan-700: #0E7090;
|
||||
--color-cyan-800: #155B75;
|
||||
--color-cyan-900: #155B75;
|
||||
--color-cyan-tint-5: --alpha(var(--color-cyan-500) / 5%);
|
||||
--color-cyan-tint-10: --alpha(var(--color-cyan-500) / 10%);
|
||||
|
||||
/* Blue scale */
|
||||
--color-blue-25: #F5FAFF;
|
||||
--color-blue-50: #EFF8FF;
|
||||
--color-blue-100: #D1E9FF;
|
||||
--color-blue-200: #B2DDFF;
|
||||
--color-blue-300: #84CAFF;
|
||||
--color-blue-400: #53B1FD;
|
||||
--color-blue-500: #2E90FA;
|
||||
--color-blue-600: #1570EF;
|
||||
--color-blue-700: #175CD3;
|
||||
--color-blue-800: #1849A9;
|
||||
--color-blue-900: #194185;
|
||||
--color-blue-tint-5: --alpha(var(--color-blue-500) / 5%);
|
||||
--color-blue-tint-10: --alpha(var(--color-blue-500) / 10%);
|
||||
|
||||
/* Indigo scale */
|
||||
--color-indigo-25: #F5F8FF;
|
||||
--color-indigo-50: #EFF4FF;
|
||||
--color-indigo-100: #E0EAFF;
|
||||
--color-indigo-200: #C7D7FE;
|
||||
--color-indigo-300: #A4BCFD;
|
||||
--color-indigo-400: #8098F9;
|
||||
--color-indigo-500: #6172F3;
|
||||
--color-indigo-600: #444CE7;
|
||||
--color-indigo-700: #3538CD;
|
||||
--color-indigo-800: #2D31A6;
|
||||
--color-indigo-900: #2D3282;
|
||||
--color-indigo-tint-5: --alpha(var(--color-indigo-500) / 5%);
|
||||
--color-indigo-tint-10: --alpha(var(--color-indigo-500) / 10%);
|
||||
|
||||
/* Violet scale */
|
||||
--color-violet-25: #FBFAFF;
|
||||
--color-violet-50: #F5F3FF;
|
||||
--color-violet-100: #ECE9FE;
|
||||
--color-violet-200: #DDD6FE;
|
||||
--color-violet-300: #C3B5FD;
|
||||
--color-violet-400: #A48AFB;
|
||||
--color-violet-500: #875BF7;
|
||||
--color-violet-600: #7839EE;
|
||||
--color-violet-700: #6927DA;
|
||||
--color-violet-tint-5: --alpha(var(--color-violet-500) / 5%);
|
||||
--color-violet-tint-10: --alpha(var(--color-violet-500) / 10%);
|
||||
|
||||
/* Fuchsia scale */
|
||||
--color-fuchsia-25: #FEFAFF;
|
||||
--color-fuchsia-50: #FDF4FF;
|
||||
--color-fuchsia-100: #FBE8FF;
|
||||
--color-fuchsia-200: #F6D0FE;
|
||||
--color-fuchsia-300: #EEAAFD;
|
||||
--color-fuchsia-400: #E478FA;
|
||||
--color-fuchsia-500: #D444F1;
|
||||
--color-fuchsia-600: #BA24D5;
|
||||
--color-fuchsia-700: #9F1AB1;
|
||||
--color-fuchsia-800: #821890;
|
||||
--color-fuchsia-900: #6F1877;
|
||||
--color-fuchsia-tint-5: --alpha(var(--color-fuchsia-500) / 5%);
|
||||
--color-fuchsia-tint-10: --alpha(var(--color-fuchsia-500) / 10%);
|
||||
|
||||
/* Pink scale */
|
||||
--color-pink-25: #FFFAFC;
|
||||
--color-pink-50: #FEF0F7;
|
||||
--color-pink-100: #FFD1E2;
|
||||
--color-pink-200: #FFB1CE;
|
||||
--color-pink-300: #FD8FBA;
|
||||
--color-pink-400: #F86BA7;
|
||||
--color-pink-500: #F23E94;
|
||||
--color-pink-600: #D5327F;
|
||||
--color-pink-700: #BA256B;
|
||||
--color-pink-800: #9E1958;
|
||||
--color-pink-900: #840B45;
|
||||
--color-pink-tint-5: --alpha(var(--color-pink-500) / 5%);
|
||||
--color-pink-tint-10: --alpha(var(--color-pink-500) / 10%);
|
||||
|
||||
/* Orange scale */
|
||||
--color-orange-25: #FFF9F5;
|
||||
--color-orange-50: #FFF4ED;
|
||||
--color-orange-100: #FFE6D5;
|
||||
--color-orange-200: #FFD6AE;
|
||||
--color-orange-300: #FF9C66;
|
||||
--color-orange-400: #FF692E;
|
||||
--color-orange-500: #FF4405;
|
||||
--color-orange-600: #E62E05;
|
||||
--color-orange-700: #BC1B06;
|
||||
--color-orange-800: #97180C;
|
||||
--color-orange-900: #771A0D;
|
||||
--color-orange-tint-5: --alpha(var(--color-orange-500) / 5%);
|
||||
--color-orange-tint-10: --alpha(var(--color-orange-500) / 10%);
|
||||
|
||||
/* Border radius overrides */
|
||||
--border-radius-md: 8px;
|
||||
--border-radius-lg: 10px;
|
||||
|
||||
--shadow-xs: 0px 1px 2px 0px --alpha(var(--color-black) / 6%);
|
||||
--shadow-sm: 0px 1px 6px 0px --alpha(var(--color-black) / 6%);
|
||||
--shadow-md: 0px 4px 8px -2px --alpha(var(--color-black) / 6%);
|
||||
--shadow-lg: 0px 12px 16px -4px --alpha(var(--color-black) / 6%);
|
||||
--shadow-xl: 0px 20px 24px -4px --alpha(var(--color-black) / 6%);
|
||||
}
|
||||
|
||||
/* Custom shadow borders used for surfaces / containers */
|
||||
@utility shadow-border-xs {
|
||||
box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-black-50);
|
||||
}
|
||||
|
||||
@utility shadow-border-sm {
|
||||
box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-black-50);
|
||||
}
|
||||
|
||||
@utility shadow-border-md {
|
||||
box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-black-50);
|
||||
}
|
||||
|
||||
@utility shadow-border-lg {
|
||||
box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-black-50);
|
||||
}
|
||||
|
||||
@utility shadow-border-xl {
|
||||
box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-black-50);
|
||||
}
|
||||
|
||||
/* Design system color utilities */
|
||||
@utility text-primary {
|
||||
@apply text-gray-900;
|
||||
}
|
||||
|
||||
@utility text-secondary {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
|
||||
@utility text-subdued {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
@utility text-link {
|
||||
@apply text-blue-600;
|
||||
}
|
||||
|
||||
@utility bg-surface {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
@utility bg-surface-hover {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
@utility bg-surface-inset {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
@utility bg-surface-inset-hover {
|
||||
@apply bg-gray-200;
|
||||
}
|
||||
|
||||
@utility bg-container {
|
||||
@apply bg-white;
|
||||
}
|
||||
|
||||
@utility bg-container-hover {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
@utility bg-container-inset {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
@utility bg-container-inset-hover {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
@utility bg-inverse {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
|
||||
@utility bg-inverse-hover {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
|
||||
@utility bg-overlay {
|
||||
@apply bg-alpha-black-200;
|
||||
}
|
||||
|
||||
@utility border-primary {
|
||||
@apply border-alpha-black-300;
|
||||
}
|
||||
|
||||
@utility border-secondary {
|
||||
@apply border-alpha-black-200;
|
||||
}
|
||||
|
||||
@utility border-tertiary {
|
||||
@apply border-alpha-black-100;
|
||||
}
|
||||
|
||||
@utility border-subdued {
|
||||
@apply border-alpha-black-50;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
form>button {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
hr {
|
||||
@apply text-gray-200;
|
||||
}
|
||||
|
||||
details>summary::-webkit-details-marker {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Buttons */
|
||||
.btn {
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
@apply bg-gray-50 hover:bg-gray-100 text-gray-900;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
@apply border border-transparent text-gray-900 hover:bg-gray-100;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.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 text-ellipsis overflow-hidden whitespace-nowrap;
|
||||
|
||||
&select {
|
||||
@apply pr-8;
|
||||
}
|
||||
}
|
||||
|
||||
.form-field__radio {
|
||||
@apply text-gray-900;
|
||||
}
|
||||
|
||||
.form-field__submit {
|
||||
@apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
||||
}
|
||||
|
||||
/* Checkboxes */
|
||||
.checkbox {
|
||||
&[type='checkbox'] {
|
||||
@apply rounded-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox--light {
|
||||
&[type='checkbox'] {
|
||||
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
|
||||
}
|
||||
|
||||
&[type='checkbox']:disabled {
|
||||
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox--dark {
|
||||
&[type='checkbox'] {
|
||||
@apply ring-gray-900 checked:text-white;
|
||||
}
|
||||
|
||||
&[type='checkbox']:disabled {
|
||||
@apply cursor-not-allowed opacity-80 ring-gray-600;
|
||||
}
|
||||
|
||||
&[type='checkbox']:checked {
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 peer-checked:bg-green-600 peer-checked:after:translate-x-4;
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
.tooltip {
|
||||
@apply hidden absolute;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
class Account::CashesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
|
||||
def index
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
end
|
||||
@@ -1,61 +0,0 @@
|
||||
class Account::EntriesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: %i[edit update show destroy]
|
||||
|
||||
def index
|
||||
@q = search_params
|
||||
@pagy, @entries = pagy(@account.entries.search(@q).reverse_chronological, limit: params[:per_page] || "10")
|
||||
end
|
||||
|
||||
def edit
|
||||
render entryable_view_path(:edit)
|
||||
end
|
||||
|
||||
def update
|
||||
prev_amount = @entry.amount
|
||||
prev_date = @entry.date
|
||||
|
||||
@entry.update!(entry_params)
|
||||
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
render entryable_view_path(:show)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@entry.destroy!
|
||||
@entry.sync_account_later
|
||||
redirect_to account_url(@entry.account), notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def entryable_view_path(action)
|
||||
@entry.entryable_type.underscore.pluralize + "/" + action.to_s
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = @account.entries.find(params[:id])
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:name, :date, :amount, :currency, :notes)
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.fetch(:q, {})
|
||||
.permit(:search)
|
||||
end
|
||||
end
|
||||
@@ -1,11 +1,8 @@
|
||||
class Account::HoldingsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_holding, only: %i[show destroy]
|
||||
|
||||
def index
|
||||
@holdings = @account.holdings.current
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def show
|
||||
@@ -13,16 +10,17 @@ class Account::HoldingsController < ApplicationController
|
||||
|
||||
def destroy
|
||||
@holding.destroy_holding_and_entries!
|
||||
redirect_back_or_to account_holdings_path(@account)
|
||||
|
||||
flash[:notice] = t(".success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@holding.account) }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, account_path(@holding.account)) }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_holding
|
||||
@holding = @account.holdings.current.find(params[:id])
|
||||
@holding = Current.family.holdings.find(params[:id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,66 +1,37 @@
|
||||
class Account::TradesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
include EntryableResource
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: :update
|
||||
|
||||
def new
|
||||
@entry = @account.entries.account_trades.new(entryable_attributes: {})
|
||||
end
|
||||
|
||||
def index
|
||||
@entries = @account.entries.reverse_chronological.where(entryable_type: %w[Account::Trade Account::Transaction])
|
||||
end
|
||||
|
||||
def create
|
||||
@builder = Account::EntryBuilder.new(entry_params)
|
||||
|
||||
if entry = @builder.save
|
||||
entry.sync_account_later
|
||||
redirect_to @account, notice: t(".success")
|
||||
else
|
||||
flash[:alert] = t(".failure")
|
||||
redirect_back_or_to @account
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@entry.update!(entry_params)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
|
||||
end
|
||||
end
|
||||
|
||||
def securities
|
||||
query = params[:q]
|
||||
return render json: [] if query.blank? || query.length < 2 || query.length > 100
|
||||
|
||||
@securities = Security::SynthComboboxOption.find_in_synth(query)
|
||||
end
|
||||
permitted_entryable_attributes :id, :qty, :price
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
def build_entry
|
||||
Account::TradeBuilder.new(create_entry_params)
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = @account.entries.find(params[:id])
|
||||
def create_entry_params
|
||||
params.require(:account_entry).permit(
|
||||
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :type, :transfer_account_id
|
||||
).tap do |params|
|
||||
account_id = params.delete(:account_id)
|
||||
params[:account] = Current.family.accounts.find(account_id)
|
||||
end
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(
|
||||
:type, :date, :qty, :ticker, :price, :amount, :notes, :excluded, :currency, :transfer_account_id, :entryable_type,
|
||||
entryable_attributes: [
|
||||
:id,
|
||||
:qty,
|
||||
:ticker,
|
||||
:price
|
||||
]
|
||||
)
|
||||
.merge(account: @account)
|
||||
def update_entry_params
|
||||
return entry_params unless entry_params[:entryable_attributes].present?
|
||||
|
||||
update_params = entry_params
|
||||
update_params = update_params.merge(entryable_type: "Account::Trade")
|
||||
|
||||
qty = update_params[:entryable_attributes][:qty]
|
||||
price = update_params[:entryable_attributes][:price]
|
||||
|
||||
if qty.present? && price.present?
|
||||
qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d
|
||||
update_params[:entryable_attributes][:qty] = qty
|
||||
update_params[:amount] = qty * price.to_d
|
||||
end
|
||||
|
||||
update_params.except(:nature)
|
||||
end
|
||||
end
|
||||
|
||||
22
app/controllers/account/transaction_categories_controller.rb
Normal file
22
app/controllers/account/transaction_categories_controller.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class Account::TransactionCategoriesController < ApplicationController
|
||||
def update
|
||||
@entry = Current.family.entries.account_transactions.find(params[:transaction_id])
|
||||
@entry.update!(entry_params)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_transaction_path(@entry) }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"category_menu_account_transaction_#{@entry.account_transaction_id}",
|
||||
partial: "categories/menu",
|
||||
locals: { transaction: @entry.account_transaction }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ])
|
||||
end
|
||||
end
|
||||
@@ -1,74 +1,37 @@
|
||||
class Account::TransactionsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
include EntryableResource
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: :update
|
||||
permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] }
|
||||
|
||||
def index
|
||||
@pagy, @entries = pagy(
|
||||
@account.entries.account_transactions.reverse_chronological,
|
||||
limit: params[:per_page] || "10"
|
||||
)
|
||||
def bulk_delete
|
||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
||||
destroyed.map(&:account).uniq.each(&:sync_later)
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||
end
|
||||
|
||||
def update
|
||||
prev_amount = @entry.amount
|
||||
prev_date = @entry.date
|
||||
def bulk_edit
|
||||
end
|
||||
|
||||
@entry.update!(entry_params.except(:origin))
|
||||
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
|
||||
def bulk_update
|
||||
updated = Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.bulk_update!(bulk_update_params)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
@entry,
|
||||
partial: "account/entries/entry",
|
||||
locals: entry_locals.merge(entry: @entry)
|
||||
)
|
||||
end
|
||||
end
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
|
||||
end
|
||||
|
||||
private
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
def bulk_delete_params
|
||||
params.require(:bulk_delete).permit(entry_ids: [])
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = @account.entries.find(params[:id])
|
||||
def bulk_update_params
|
||||
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
|
||||
end
|
||||
|
||||
def entry_locals
|
||||
{
|
||||
selectable: entry_params[:origin].present?,
|
||||
show_balance: entry_params[:origin] == "account",
|
||||
origin: entry_params[:origin]
|
||||
}
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :entryable_type, :nature, :origin,
|
||||
entryable_attributes: [
|
||||
:id,
|
||||
:category_id,
|
||||
:merchant_id,
|
||||
{ tag_ids: [] }
|
||||
]
|
||||
).tap do |permitted_params|
|
||||
nature = permitted_params.delete(:nature)
|
||||
|
||||
if permitted_params[:amount]
|
||||
amount_value = permitted_params[:amount].to_d
|
||||
|
||||
if nature == "income"
|
||||
amount_value *= -1
|
||||
end
|
||||
|
||||
permitted_params[:amount] = amount_value
|
||||
end
|
||||
end
|
||||
def search_params
|
||||
params.fetch(:q, {})
|
||||
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
|
||||
end
|
||||
end
|
||||
|
||||
56
app/controllers/account/transfer_matches_controller.rb
Normal file
56
app/controllers/account/transfer_matches_controller.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
class Account::TransferMatchesController < ApplicationController
|
||||
before_action :set_entry
|
||||
|
||||
def new
|
||||
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
|
||||
@transfer_match_candidates = @entry.transfer_match_candidates
|
||||
end
|
||||
|
||||
def create
|
||||
@transfer = build_transfer
|
||||
@transfer.save!
|
||||
@transfer.sync_account_later
|
||||
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def set_entry
|
||||
@entry = Current.family.entries.find(params[:transaction_id])
|
||||
end
|
||||
|
||||
def transfer_match_params
|
||||
params.require(:transfer_match).permit(:method, :matched_entry_id, :target_account_id)
|
||||
end
|
||||
|
||||
def build_transfer
|
||||
if transfer_match_params[:method] == "new"
|
||||
target_account = Current.family.accounts.find(transfer_match_params[:target_account_id])
|
||||
|
||||
missing_transaction = Account::Transaction.new(
|
||||
entry: target_account.entries.build(
|
||||
amount: @entry.amount * -1,
|
||||
currency: @entry.currency,
|
||||
date: @entry.date,
|
||||
name: "Transfer to #{@entry.amount.negative? ? @entry.account.name : target_account.name}",
|
||||
)
|
||||
)
|
||||
|
||||
transfer = Transfer.find_or_initialize_by(
|
||||
inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.account_transaction,
|
||||
outflow_transaction: @entry.amount.positive? ? @entry.account_transaction : missing_transaction
|
||||
)
|
||||
transfer.status = "confirmed"
|
||||
transfer
|
||||
else
|
||||
target_transaction = Current.family.entries.find(transfer_match_params[:matched_entry_id])
|
||||
|
||||
transfer = Transfer.find_or_initialize_by(
|
||||
inflow_transaction: @entry.amount.negative? ? @entry.account_transaction : target_transaction.account_transaction,
|
||||
outflow_transaction: @entry.amount.negative? ? target_transaction.account_transaction : @entry.account_transaction
|
||||
)
|
||||
transfer.status = "confirmed"
|
||||
transfer
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,62 +0,0 @@
|
||||
class Account::TransfersController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_transfer, only: %i[destroy show update]
|
||||
|
||||
def new
|
||||
@transfer = Account::Transfer.new
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
|
||||
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
|
||||
|
||||
@transfer = Account::Transfer.build_from_accounts from_account, to_account, \
|
||||
date: transfer_params[:date],
|
||||
amount: transfer_params[:amount].to_d,
|
||||
currency: transfer_params[:currency]
|
||||
|
||||
if @transfer.save
|
||||
@transfer.entries.each(&:sync_account_later)
|
||||
redirect_to transactions_path, notice: t(".success")
|
||||
else
|
||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
||||
flash[:alert] = @transfer.errors.full_messages.to_sentence
|
||||
redirect_to transactions_path
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@transfer.update_entries!(transfer_update_params)
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transfer.destroy!
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_transfer
|
||||
record = Account::Transfer.find(params[:id])
|
||||
|
||||
unless record.entries.all? { |entry| Current.family.accounts.include?(entry.account) }
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
|
||||
@transfer = record
|
||||
end
|
||||
|
||||
def transfer_params
|
||||
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
|
||||
end
|
||||
|
||||
def transfer_update_params
|
||||
params.require(:account_transfer).permit(:excluded, :notes)
|
||||
end
|
||||
end
|
||||
@@ -1,35 +1,3 @@
|
||||
class Account::ValuationsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account
|
||||
|
||||
def new
|
||||
@entry = @account.entries.account_valuations.new(entryable_attributes: {})
|
||||
end
|
||||
|
||||
def create
|
||||
@entry = @account.entries.account_valuations.new(entry_params.merge(entryable_attributes: {}))
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
redirect_back_or_to account_valuations_path(@account), notice: t(".success")
|
||||
else
|
||||
flash[:alert] = @entry.errors.full_messages.to_sentence
|
||||
redirect_to @account
|
||||
end
|
||||
end
|
||||
|
||||
def index
|
||||
@entries = @account.entries.account_valuations.reverse_chronological
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:name, :date, :amount, :currency)
|
||||
end
|
||||
include EntryableResource
|
||||
end
|
||||
|
||||
25
app/controllers/accountable_sparklines_controller.rb
Normal file
25
app/controllers/accountable_sparklines_controller.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
class AccountableSparklinesController < ApplicationController
|
||||
def show
|
||||
@accountable = Accountable.from_type(params[:accountable_type]&.classify)
|
||||
|
||||
@series = Rails.cache.fetch(cache_key) do
|
||||
family.accounts.active
|
||||
.where(accountable_type: @accountable.name)
|
||||
.balance_series(
|
||||
currency: family.currency,
|
||||
favorable_direction: @accountable.favorable_direction
|
||||
)
|
||||
end
|
||||
|
||||
render layout: false
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
Current.family
|
||||
end
|
||||
|
||||
def cache_key
|
||||
family.build_cache_key("#{@accountable.name}_sparkline")
|
||||
end
|
||||
end
|
||||
@@ -1,41 +1,43 @@
|
||||
class AccountsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account, only: %i[sync]
|
||||
before_action :set_account, only: %i[sync chart sparkline]
|
||||
|
||||
def index
|
||||
@institutions = Current.family.institutions
|
||||
@accounts = Current.family.accounts.ungrouped.alphabetically
|
||||
end
|
||||
@manual_accounts = family.accounts.manual.alphabetically
|
||||
@plaid_items = family.plaid_items.ordered
|
||||
|
||||
def summary
|
||||
@period = Period.from_param(params[:period])
|
||||
snapshot = Current.family.snapshot(@period)
|
||||
@net_worth_series = snapshot[:net_worth_series]
|
||||
@asset_series = snapshot[:asset_series]
|
||||
@liability_series = snapshot[:liability_series]
|
||||
@accounts = Current.family.accounts
|
||||
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
end
|
||||
|
||||
def list
|
||||
@period = Period.from_param(params[:period])
|
||||
render layout: false
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def sync
|
||||
unless @account.syncing?
|
||||
@account.sync_later
|
||||
end
|
||||
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
|
||||
def chart
|
||||
render layout: "application"
|
||||
end
|
||||
|
||||
def sparkline
|
||||
render layout: false
|
||||
end
|
||||
|
||||
def sync_all
|
||||
Current.family.accounts.active.sync
|
||||
redirect_back_or_to accounts_path, notice: t(".success")
|
||||
unless family.syncing?
|
||||
family.sync_later
|
||||
end
|
||||
|
||||
redirect_to accounts_path
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
Current.family
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
@account = family.accounts.find(params[:id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,12 +4,15 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
helper_method :require_upgrade?, :subscription_pending?
|
||||
|
||||
before_action :detect_os
|
||||
|
||||
private
|
||||
def require_upgrade?
|
||||
return false if self_hosted?
|
||||
return false unless Current.session
|
||||
return false if Current.family.subscribed?
|
||||
return false if subscription_pending? || request.path == settings_billing_path
|
||||
return false if Current.family.active_accounts_count <= 3
|
||||
|
||||
true
|
||||
end
|
||||
@@ -19,9 +22,15 @@ class ApplicationController < ActionController::Base
|
||||
subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago
|
||||
end
|
||||
|
||||
def with_sidebar
|
||||
return "turbo_rails/frame" if turbo_frame_request?
|
||||
|
||||
"with_sidebar"
|
||||
def detect_os
|
||||
user_agent = request.user_agent
|
||||
@os = case user_agent
|
||||
when /Windows/i then "windows"
|
||||
when /Macintosh/i then "mac"
|
||||
when /Linux/i then "linux"
|
||||
when /Android/i then "android"
|
||||
when /iPhone|iPad/i then "ios"
|
||||
else ""
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
48
app/controllers/budget_categories_controller.rb
Normal file
48
app/controllers/budget_categories_controller.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
class BudgetCategoriesController < ApplicationController
|
||||
before_action :set_budget
|
||||
|
||||
def index
|
||||
@budget_categories = @budget.budget_categories.includes(:category)
|
||||
render layout: "wizard"
|
||||
end
|
||||
|
||||
def show
|
||||
@recent_transactions = @budget.transactions
|
||||
|
||||
if params[:id] == BudgetCategory.uncategorized.id
|
||||
@budget_category = @budget.uncategorized_budget_category
|
||||
@recent_transactions = @recent_transactions.where(account_transactions: { category_id: nil })
|
||||
else
|
||||
@budget_category = Current.family.budget_categories.find(params[:id])
|
||||
@recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
|
||||
.where("categories.id = ? OR categories.parent_id = ?", @budget_category.category.id, @budget_category.category.id)
|
||||
end
|
||||
|
||||
@recent_transactions = @recent_transactions.order("account_entries.date DESC, ABS(account_entries.amount) DESC").take(3)
|
||||
end
|
||||
|
||||
def update
|
||||
@budget_category = Current.family.budget_categories.find(params[:id])
|
||||
|
||||
if @budget_category.update(budget_category_params)
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.html { redirect_to budget_budget_categories_path(@budget) }
|
||||
end
|
||||
else
|
||||
render :index, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def budget_category_params
|
||||
params.require(:budget_category).permit(:budgeted_spending).tap do |params|
|
||||
params[:budgeted_spending] = params[:budgeted_spending].presence || 0
|
||||
end
|
||||
end
|
||||
|
||||
def set_budget
|
||||
start_date = Budget.param_to_date(params[:budget_month_year])
|
||||
@budget = Current.family.budgets.find_by(start_date: start_date)
|
||||
end
|
||||
end
|
||||
46
app/controllers/budgets_controller.rb
Normal file
46
app/controllers/budgets_controller.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
class BudgetsController < ApplicationController
|
||||
before_action :set_budget, only: %i[show edit update]
|
||||
|
||||
def index
|
||||
redirect_to_current_month_budget
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def edit
|
||||
render layout: "wizard"
|
||||
end
|
||||
|
||||
def update
|
||||
@budget.update!(budget_params)
|
||||
redirect_to budget_budget_categories_path(@budget)
|
||||
end
|
||||
|
||||
def picker
|
||||
render partial: "budgets/picker", locals: {
|
||||
family: Current.family,
|
||||
year: params[:year].to_i || Date.current.year
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def budget_create_params
|
||||
params.require(:budget).permit(:start_date)
|
||||
end
|
||||
|
||||
def budget_params
|
||||
params.require(:budget).permit(:budgeted_spending, :expected_income)
|
||||
end
|
||||
|
||||
def set_budget
|
||||
start_date = Budget.param_to_date(params[:month_year])
|
||||
@budget = Budget.find_or_bootstrap(Current.family, start_date: start_date)
|
||||
raise ActiveRecord::RecordNotFound unless @budget
|
||||
end
|
||||
|
||||
def redirect_to_current_month_budget
|
||||
current_budget = Budget.find_or_bootstrap(Current.family, start_date: Date.current)
|
||||
redirect_to budget_path(current_budget)
|
||||
end
|
||||
end
|
||||
@@ -1,15 +1,17 @@
|
||||
class CategoriesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_category, only: %i[edit update destroy]
|
||||
before_action :set_categories, only: %i[update edit]
|
||||
before_action :set_transaction, only: :create
|
||||
|
||||
def index
|
||||
@categories = Current.family.categories.alphabetically
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def new
|
||||
@category = Current.family.categories.new color: Category::COLORS.sample
|
||||
set_categories
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -17,9 +19,17 @@ class CategoriesController < ApplicationController
|
||||
|
||||
if @category.save
|
||||
@transaction.update(category_id: @category.id) if @transaction
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
|
||||
flash[:notice] = t(".success")
|
||||
|
||||
redirect_target_url = request.referer || categories_path
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to categories_path, notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
else
|
||||
redirect_back_or_to transactions_path, alert: t(".failure", error: @category.errors.full_messages.to_sentence)
|
||||
set_categories
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,9 +37,17 @@ class CategoriesController < ApplicationController
|
||||
end
|
||||
|
||||
def update
|
||||
@category.update! category_params
|
||||
if @category.update(category_params)
|
||||
flash[:notice] = t(".success")
|
||||
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
redirect_target_url = request.referer || categories_path
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to categories_path, notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -38,11 +56,25 @@ class CategoriesController < ApplicationController
|
||||
redirect_back_or_to categories_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def bootstrap
|
||||
Current.family.categories.bootstrap_defaults
|
||||
|
||||
redirect_back_or_to categories_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def set_category
|
||||
@category = Current.family.categories.find(params[:id])
|
||||
end
|
||||
|
||||
def set_categories
|
||||
@categories = unless @category.parent?
|
||||
Current.family.categories.alphabetically.roots.where.not(id: @category.id)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def set_transaction
|
||||
if params[:transaction_id].present?
|
||||
@transaction = Current.family.transactions.find(params[:transaction_id])
|
||||
@@ -50,6 +82,6 @@ class CategoriesController < ApplicationController
|
||||
end
|
||||
|
||||
def category_params
|
||||
params.require(:category).permit(:name, :color)
|
||||
params.require(:category).permit(:name, :color, :parent_id, :classification, :lucide_icon)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class Category::DeletionsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_category
|
||||
before_action :set_replacement_category, only: :create
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ module AccountableResource
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
layout :with_sidebar
|
||||
include ScrollFocusable
|
||||
|
||||
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
|
||||
before_action :set_link_token, only: :new
|
||||
end
|
||||
|
||||
class_methods do
|
||||
@@ -16,12 +18,17 @@ module AccountableResource
|
||||
def new
|
||||
@account = Current.family.accounts.build(
|
||||
currency: Current.family.currency,
|
||||
accountable: accountable_type.new,
|
||||
institution_id: params[:institution_id]
|
||||
accountable: accountable_type.new
|
||||
)
|
||||
end
|
||||
|
||||
def show
|
||||
@q = params.fetch(:q, {}).permit(:search)
|
||||
entries = @account.entries.search(@q).reverse_chronological
|
||||
|
||||
set_focused_record(entries, params[:focused_record_id])
|
||||
|
||||
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10", params: ->(params) { params.except(:focused_record_id) })
|
||||
end
|
||||
|
||||
def edit
|
||||
@@ -29,20 +36,50 @@ module AccountableResource
|
||||
|
||||
def create
|
||||
@account = Current.family.accounts.create_and_sync(account_params.except(:return_to))
|
||||
redirect_to account_params[:return_to].presence || @account, notice: t(".success")
|
||||
redirect_to account_params[:return_to].presence || @account, notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params.except(:return_to))
|
||||
redirect_back_or_to @account, notice: t(".success")
|
||||
redirect_back_or_to @account, notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@account.destroy!
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
@account.destroy_later
|
||||
redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
private
|
||||
def set_link_token
|
||||
@us_link_token = Current.family.get_link_token(
|
||||
webhooks_url: plaid_us_webhooks_url,
|
||||
redirect_url: accounts_url,
|
||||
accountable_type: accountable_type.name,
|
||||
region: :us
|
||||
)
|
||||
|
||||
if Current.family.eu?
|
||||
@eu_link_token = Current.family.get_link_token(
|
||||
webhooks_url: plaid_eu_webhooks_url,
|
||||
redirect_url: accounts_url,
|
||||
accountable_type: accountable_type.name,
|
||||
region: :eu
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def plaid_us_webhooks_url
|
||||
return webhooks_plaid_url if Rails.env.production?
|
||||
|
||||
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid"
|
||||
end
|
||||
|
||||
def plaid_eu_webhooks_url
|
||||
return webhooks_plaid_eu_url if Rails.env.production?
|
||||
|
||||
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid_eu"
|
||||
end
|
||||
|
||||
def accountable_type
|
||||
controller_name.classify.constantize
|
||||
end
|
||||
@@ -53,7 +90,7 @@ module AccountableResource
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(
|
||||
:name, :is_active, :balance, :subtype, :currency, :institution_id, :accountable_type, :return_to,
|
||||
:name, :is_active, :balance, :subtype, :currency, :accountable_type, :return_to,
|
||||
accountable_attributes: self.class.permitted_accountable_attributes
|
||||
)
|
||||
end
|
||||
|
||||
@@ -2,12 +2,18 @@ module AutoSync
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :sync_family, if: -> { Current.family.present? && Current.family.needs_sync? }
|
||||
before_action :sync_family, if: :family_needs_auto_sync?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_family
|
||||
Current.family.sync
|
||||
Current.family.update!(last_synced_at: Time.current)
|
||||
Current.family.sync_later
|
||||
end
|
||||
|
||||
def family_needs_auto_sync?
|
||||
return false unless Current.family.present?
|
||||
|
||||
(Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current
|
||||
end
|
||||
end
|
||||
|
||||
128
app/controllers/concerns/entryable_resource.rb
Normal file
128
app/controllers/concerns/entryable_resource.rb
Normal file
@@ -0,0 +1,128 @@
|
||||
module EntryableResource
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_entry, only: %i[show update destroy]
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def permitted_entryable_attributes(*attrs)
|
||||
@permitted_entryable_attributes = attrs if attrs.any?
|
||||
@permitted_entryable_attributes ||= [ :id ]
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def new
|
||||
account = Current.family.accounts.find_by(id: params[:account_id])
|
||||
|
||||
@entry = Current.family.entries.new(
|
||||
account: account,
|
||||
currency: account ? account.currency : Current.family.currency,
|
||||
entryable: entryable_type.new
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@entry = build_entry
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
|
||||
flash[:notice] = t("account.entries.create.success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account) }
|
||||
|
||||
redirect_target_url = request.referer || account_path(@entry.account)
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @entry.update(update_entry_params)
|
||||
@entry.sync_account_later
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"header_account_entry_#{@entry.id}",
|
||||
partial: "#{entryable_type.name.underscore.pluralize}/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry })
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
account = @entry.account
|
||||
@entry.destroy!
|
||||
@entry.sync_account_later
|
||||
|
||||
flash[:notice] = t("account.entries.destroy.success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(account) }
|
||||
|
||||
redirect_target_url = request.referer || account_path(@entry.account)
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def entryable_type
|
||||
permitted_entryable_types = %w[Account::Transaction Account::Valuation Account::Trade]
|
||||
klass = params[:entryable_type] || "Account::#{controller_name.classify}"
|
||||
klass.constantize if permitted_entryable_types.include?(klass)
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = Current.family.entries.find(params[:id])
|
||||
end
|
||||
|
||||
def build_entry
|
||||
Current.family.entries.new(create_entry_params)
|
||||
end
|
||||
|
||||
def update_entry_params
|
||||
prepared_entry_params
|
||||
end
|
||||
|
||||
def create_entry_params
|
||||
prepared_entry_params.merge({
|
||||
entryable_type: entryable_type.name,
|
||||
entryable_attributes: entry_params[:entryable_attributes] || {}
|
||||
})
|
||||
end
|
||||
|
||||
def prepared_entry_params
|
||||
default_params = entry_params.except(:nature)
|
||||
default_params = default_params.merge(entryable_type: entryable_type.name) if entry_params[:entryable_attributes].present?
|
||||
|
||||
if entry_params[:nature].present? && entry_params[:amount].present?
|
||||
signed_amount = entry_params[:nature] == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d
|
||||
default_params = default_params.merge(amount: signed_amount)
|
||||
end
|
||||
|
||||
default_params
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(
|
||||
:account_id, :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
entryable_attributes: self.class.permitted_entryable_attributes
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@ module Localize
|
||||
|
||||
included do
|
||||
around_action :switch_locale
|
||||
around_action :switch_timezone
|
||||
end
|
||||
|
||||
private
|
||||
@@ -10,4 +11,9 @@ module Localize
|
||||
locale = Current.family.try(:locale) || I18n.default_locale
|
||||
I18n.with_locale(locale, &action)
|
||||
end
|
||||
|
||||
def switch_timezone(&action)
|
||||
timezone = Current.family.try(:timezone) || Time.zone
|
||||
Time.use_zone(timezone, &action)
|
||||
end
|
||||
end
|
||||
|
||||
21
app/controllers/concerns/scroll_focusable.rb
Normal file
21
app/controllers/concerns/scroll_focusable.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
module ScrollFocusable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def set_focused_record(record_scope, record_id, default_per_page: 10)
|
||||
return unless record_id.present?
|
||||
|
||||
@focused_record = record_scope.find_by(id: record_id)
|
||||
|
||||
record_index = record_scope.pluck(:id).index(record_id)
|
||||
|
||||
return unless record_index
|
||||
|
||||
page_of_focused_record = (record_index / (params[:per_page]&.to_i || default_per_page)) + 1
|
||||
|
||||
if params[:page]&.to_i != page_of_focused_record
|
||||
(
|
||||
redirect_to(url_for(page: page_of_focused_record, focused_record_id: record_id))
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,8 @@ module StoreLocation
|
||||
helper_method :previous_path
|
||||
before_action :store_return_to
|
||||
after_action :clear_previous_path
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
|
||||
end
|
||||
|
||||
def previous_path
|
||||
@@ -12,6 +14,14 @@ module StoreLocation
|
||||
end
|
||||
|
||||
private
|
||||
def handle_not_found
|
||||
if request.fullpath == session[:return_to]
|
||||
session.delete(:return_to)
|
||||
redirect_to fallback_path
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
def store_return_to
|
||||
if params[:return_to].present?
|
||||
|
||||
18
app/controllers/email_confirmations_controller.rb
Normal file
18
app/controllers/email_confirmations_controller.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
class EmailConfirmationsController < ApplicationController
|
||||
skip_before_action :set_request_details, only: :new
|
||||
skip_authentication only: :new
|
||||
|
||||
def new
|
||||
# Returns nil if the token is invalid OR expired
|
||||
@user = User.find_by_token_for(:email_confirmation, params[:token])
|
||||
|
||||
if @user&.unconfirmed_email && @user&.update(
|
||||
email: @user.unconfirmed_email,
|
||||
unconfirmed_email: nil
|
||||
)
|
||||
redirect_to new_session_path, notice: t(".success_login")
|
||||
else
|
||||
redirect_to root_path, alert: t(".invalid_token")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,11 +0,0 @@
|
||||
class Help::ArticlesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
def show
|
||||
@article = Help::Article.find(params[:id])
|
||||
|
||||
unless @article
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -32,7 +32,7 @@ class Import::UploadsController < ApplicationController
|
||||
require "csv"
|
||||
|
||||
begin
|
||||
csv = CSV.parse(str || "", headers: true)
|
||||
csv = CSV.parse(str || "", headers: true, 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]
|
||||
before_action :set_import, only: %i[show publish destroy revert]
|
||||
|
||||
def publish
|
||||
@import.publish_later
|
||||
@@ -10,7 +10,7 @@ class ImportsController < ApplicationController
|
||||
def index
|
||||
@imports = Current.family.imports
|
||||
|
||||
render layout: with_sidebar
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def new
|
||||
@@ -31,6 +31,11 @@ class ImportsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def revert
|
||||
@import.revert_later
|
||||
redirect_to imports_path, notice: "Import is reverting in the background."
|
||||
end
|
||||
|
||||
def destroy
|
||||
@import.destroy
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
class InstitutionsController < ApplicationController
|
||||
before_action :set_institution, except: %i[new create]
|
||||
|
||||
def new
|
||||
@institution = Institution.new
|
||||
end
|
||||
|
||||
def create
|
||||
Current.family.institutions.create!(institution_params)
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@institution.update!(institution_params)
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@institution.destroy!
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def sync
|
||||
@institution.sync
|
||||
redirect_back_or_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def institution_params
|
||||
params.require(:institution).permit(:name, :logo)
|
||||
end
|
||||
|
||||
def set_institution
|
||||
@institution = Current.family.institutions.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -34,6 +34,24 @@ class InvitationsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
unless Current.user.admin?
|
||||
flash[:alert] = t("invitations.destroy.not_authorized")
|
||||
redirect_to settings_profile_path
|
||||
return
|
||||
end
|
||||
|
||||
@invitation = Current.family.invitations.find(params[:id])
|
||||
|
||||
if @invitation.destroy
|
||||
flash[:notice] = t("invitations.destroy.success")
|
||||
else
|
||||
flash[:alert] = t("invitations.destroy.failure")
|
||||
end
|
||||
|
||||
redirect_to settings_profile_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def invitation_params
|
||||
|
||||
@@ -6,6 +6,7 @@ class InviteCodesController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
raise StandardError, "You are not allowed to generate invite codes" unless Current.user.admin?
|
||||
InviteCode.generate!
|
||||
redirect_back_or_to invite_codes_path, notice: "Code generated"
|
||||
end
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
class MerchantsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_merchant, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@merchants = Current.family.merchants.alphabetically
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def new
|
||||
|
||||
53
app/controllers/mfa_controller.rb
Normal file
53
app/controllers/mfa_controller.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
class MfaController < ApplicationController
|
||||
layout :determine_layout
|
||||
skip_authentication only: [ :verify, :verify_code ]
|
||||
|
||||
def new
|
||||
redirect_to root_path if Current.user.otp_required?
|
||||
Current.user.setup_mfa! unless Current.user.otp_secret.present?
|
||||
end
|
||||
|
||||
def create
|
||||
if Current.user.verify_otp?(params[:code])
|
||||
Current.user.enable_mfa!
|
||||
@backup_codes = Current.user.otp_backup_codes
|
||||
render :backup_codes
|
||||
else
|
||||
Current.user.disable_mfa!
|
||||
redirect_to new_mfa_path, alert: t(".invalid_code")
|
||||
end
|
||||
end
|
||||
|
||||
def verify
|
||||
@user = User.find_by(id: session[:mfa_user_id])
|
||||
redirect_to new_session_path unless @user
|
||||
end
|
||||
|
||||
def verify_code
|
||||
@user = User.find_by(id: session[:mfa_user_id])
|
||||
|
||||
if @user&.verify_otp?(params[:code])
|
||||
session.delete(:mfa_user_id)
|
||||
@session = create_session_for(@user)
|
||||
redirect_to root_path
|
||||
else
|
||||
flash.now[:alert] = t(".invalid_code")
|
||||
render :verify, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def disable
|
||||
Current.user.disable_mfa!
|
||||
redirect_to settings_security_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def determine_layout
|
||||
if action_name.in?(%w[verify verify_code])
|
||||
"auth"
|
||||
else
|
||||
"settings"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,4 @@
|
||||
class OnboardingsController < ApplicationController
|
||||
layout "application"
|
||||
before_action :set_user
|
||||
before_action :load_invitation
|
||||
|
||||
|
||||
@@ -1,40 +1,20 @@
|
||||
class PagesController < ApplicationController
|
||||
skip_before_action :authenticate_user!, only: %i[early_access]
|
||||
layout :with_sidebar, except: %i[early_access]
|
||||
|
||||
def dashboard
|
||||
@period = Period.from_param(params[:period])
|
||||
snapshot = Current.family.snapshot(@period)
|
||||
@net_worth_series = snapshot[:net_worth_series]
|
||||
@asset_series = snapshot[:asset_series]
|
||||
@liability_series = snapshot[:liability_series]
|
||||
|
||||
snapshot_transactions = Current.family.snapshot_transactions
|
||||
@income_series = snapshot_transactions[:income_series]
|
||||
@spending_series = snapshot_transactions[:spending_series]
|
||||
@savings_rate_series = snapshot_transactions[:savings_rate_series]
|
||||
|
||||
snapshot_account_transactions = Current.family.snapshot_account_transactions
|
||||
@top_spenders = snapshot_account_transactions[:top_spenders]
|
||||
@top_earners = snapshot_account_transactions[:top_earners]
|
||||
@top_savers = snapshot_account_transactions[:top_savers]
|
||||
|
||||
@accounts = Current.family.accounts.active
|
||||
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
@transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological
|
||||
|
||||
# TODO: Placeholders for trendlines
|
||||
placeholder_series_data = 10.times.map do |i|
|
||||
{ date: Date.current - i.days, value: Money.new(0, Current.family.currency) }
|
||||
end
|
||||
@investing_series = TimeSeries.new(placeholder_series_data)
|
||||
@period = Period.from_key(params[:period], fallback: true)
|
||||
@balance_sheet = Current.family.balance_sheet
|
||||
@accounts = Current.family.accounts.active.with_attached_logo
|
||||
end
|
||||
|
||||
def changelog
|
||||
@release_notes = Provider::Github.new.fetch_latest_release_notes
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def feedback
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def early_access
|
||||
|
||||
42
app/controllers/plaid_items_controller.rb
Normal file
42
app/controllers/plaid_items_controller.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class PlaidItemsController < ApplicationController
|
||||
before_action :set_plaid_item, only: %i[destroy sync]
|
||||
|
||||
def create
|
||||
Current.family.plaid_items.create_from_public_token(
|
||||
plaid_item_params[:public_token],
|
||||
item_name: item_name,
|
||||
region: plaid_item_params[:region]
|
||||
)
|
||||
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@plaid_item.destroy_later
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def sync
|
||||
unless @plaid_item.syncing?
|
||||
@plaid_item.sync_later
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to accounts_path }
|
||||
format.json { head :ok }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_plaid_item
|
||||
@plaid_item = Current.family.plaid_items.find(params[:id])
|
||||
end
|
||||
|
||||
def plaid_item_params
|
||||
params.require(:plaid_item).permit(:public_token, :region, metadata: {})
|
||||
end
|
||||
|
||||
def item_name
|
||||
plaid_item_params.dig(:metadata, :institution, :name)
|
||||
end
|
||||
end
|
||||
@@ -11,8 +11,7 @@ class PropertiesController < ApplicationController
|
||||
currency: Current.family.currency,
|
||||
accountable: Property.new(
|
||||
address: Address.new
|
||||
),
|
||||
institution_id: params[:institution_id]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -24,11 +24,10 @@ class RegistrationsController < ApplicationController
|
||||
|
||||
if @user.save
|
||||
@invitation&.update!(accepted_at: Time.current)
|
||||
Category.create_default_categories(@user.family) unless @invitation
|
||||
@session = create_session_for(@user)
|
||||
redirect_to root_path, notice: t(".success")
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
render :new, status: :unprocessable_entity, alert: t(".failure")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
class SecuritiesController < ApplicationController
|
||||
def import
|
||||
SecuritiesImportJob.perform_later(params[:exchange_mic])
|
||||
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
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,8 +9,13 @@ class SessionsController < ApplicationController
|
||||
|
||||
def create
|
||||
if user = User.authenticate_by(email: params[:email], password: params[:password])
|
||||
@session = create_session_for(user)
|
||||
redirect_to root_path
|
||||
if user.otp_required?
|
||||
session[:mfa_user_id] = user.id
|
||||
redirect_to verify_mfa_path
|
||||
else
|
||||
@session = create_session_for(user)
|
||||
redirect_to root_path
|
||||
end
|
||||
else
|
||||
flash.now[:alert] = t(".invalid_credentials")
|
||||
render :new, status: :unprocessable_entity
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Settings::BillingsController < SettingsController
|
||||
class Settings::BillingsController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
def show
|
||||
@user = Current.user
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Settings::HostingsController < SettingsController
|
||||
class Settings::HostingsController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
before_action :raise_if_not_self_hosted
|
||||
|
||||
def show
|
||||
@@ -22,6 +24,10 @@ class Settings::HostingsController < SettingsController
|
||||
Setting.require_invite_for_signup = hosting_params[:require_invite_for_signup]
|
||||
end
|
||||
|
||||
if hosting_params.key?(:require_email_confirmation)
|
||||
Setting.require_email_confirmation = hosting_params[:require_email_confirmation]
|
||||
end
|
||||
|
||||
if hosting_params.key?(:synth_api_key)
|
||||
Setting.synth_api_key = hosting_params[:synth_api_key]
|
||||
end
|
||||
@@ -34,7 +40,7 @@ class Settings::HostingsController < SettingsController
|
||||
|
||||
private
|
||||
def hosting_params
|
||||
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :synth_api_key)
|
||||
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key)
|
||||
end
|
||||
|
||||
def raise_if_not_self_hosted
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Settings::PreferencesController < SettingsController
|
||||
class Settings::PreferencesController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
def show
|
||||
@user = Current.user
|
||||
end
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
class Settings::ProfilesController < SettingsController
|
||||
class Settings::ProfilesController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
def show
|
||||
@user = Current.user
|
||||
@users = Current.family.users.order(:created_at)
|
||||
@pending_invitations = Current.family.invitations.pending
|
||||
end
|
||||
|
||||
def destroy
|
||||
unless Current.user.admin?
|
||||
flash[:alert] = t("settings.profiles.destroy.not_authorized")
|
||||
redirect_to settings_profile_path
|
||||
return
|
||||
end
|
||||
|
||||
@user = Current.family.users.find(params[:user_id])
|
||||
|
||||
if @user == Current.user
|
||||
flash[:alert] = t("settings.profiles.destroy.cannot_remove_self")
|
||||
redirect_to settings_profile_path
|
||||
return
|
||||
end
|
||||
|
||||
if @user.destroy
|
||||
flash[:notice] = t("settings.profiles.destroy.member_removed")
|
||||
else
|
||||
flash[:alert] = t("settings.profiles.destroy.member_removal_failed")
|
||||
end
|
||||
|
||||
redirect_to settings_profile_path
|
||||
end
|
||||
end
|
||||
|
||||
6
app/controllers/settings/securities_controller.rb
Normal file
6
app/controllers/settings/securities_controller.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class Settings::SecuritiesController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
def show
|
||||
end
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class SettingsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
end
|
||||
@@ -1,6 +1,4 @@
|
||||
class Tag::DeletionsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_tag
|
||||
before_action :set_replacement_tag, only: :create
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
class TagsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_tag, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@tags = Current.family.tags.alphabetically
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def new
|
||||
|
||||
@@ -1,106 +1,102 @@
|
||||
class TransactionsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
include ScrollFocusable
|
||||
|
||||
before_action :store_params!, only: :index
|
||||
|
||||
def index
|
||||
@q = search_params
|
||||
result = Current.family.entries.account_transactions.search(@q).reverse_chronological
|
||||
@pagy, @transaction_entries = pagy(result, limit: params[:per_page] || "50")
|
||||
transactions_query = Current.family.transactions.active.search(@q)
|
||||
|
||||
@totals = {
|
||||
count: result.select { |t| t.currency == Current.family.currency }.count,
|
||||
income: result.income_total(Current.family.currency).abs,
|
||||
expense: result.expense_total(Current.family.currency)
|
||||
set_focused_record(transactions_query, params[:focused_record_id], default_per_page: 50)
|
||||
|
||||
@pagy, @transactions = pagy(
|
||||
transactions_query.includes(
|
||||
{ entry: :account },
|
||||
:category, :merchant, :tags,
|
||||
transfer_as_outflow: { inflow_transaction: { entry: :account } },
|
||||
transfer_as_inflow: { outflow_transaction: { entry: :account } }
|
||||
).reverse_chronological,
|
||||
limit: params[:per_page].presence || default_params[:per_page],
|
||||
params: ->(params) { params.except(:focused_record_id) }
|
||||
)
|
||||
|
||||
@totals = Current.family.income_statement.totals(transactions_scope: transactions_query)
|
||||
end
|
||||
|
||||
def clear_filter
|
||||
updated_params = {
|
||||
"q" => search_params,
|
||||
"page" => params[:page],
|
||||
"per_page" => params[:per_page]
|
||||
}
|
||||
end
|
||||
|
||||
def new
|
||||
@entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e|
|
||||
if params[:account_id]
|
||||
e.account = Current.family.accounts.find(params[:account_id])
|
||||
e.currency = e.account.currency
|
||||
else
|
||||
e.currency = Current.family.currency
|
||||
end
|
||||
q_params = updated_params["q"] || {}
|
||||
|
||||
param_key = params[:param_key]
|
||||
param_value = params[:param_value]
|
||||
|
||||
if q_params[param_key].is_a?(Array)
|
||||
q_params[param_key].delete(param_value)
|
||||
q_params.delete(param_key) if q_params[param_key].empty?
|
||||
else
|
||||
q_params.delete(param_key)
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
@entry = Current.family
|
||||
.accounts
|
||||
.find(params[:account_entry][:account_id])
|
||||
.entries
|
||||
.create!(transaction_entry_params.merge(amount: amount))
|
||||
updated_params["q"] = q_params.presence
|
||||
Current.session.update!(prev_transaction_page_params: updated_params)
|
||||
|
||||
@entry.sync_account_later
|
||||
redirect_back_or_to @entry.account, notice: t(".success")
|
||||
end
|
||||
|
||||
def bulk_delete
|
||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
||||
destroyed.map(&:account).uniq.each(&:sync_later)
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||
end
|
||||
|
||||
def bulk_edit
|
||||
end
|
||||
|
||||
def bulk_update
|
||||
updated = Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.bulk_update!(bulk_update_params)
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
|
||||
end
|
||||
|
||||
def mark_transfers
|
||||
Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.mark_transfers!
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def unmark_transfers
|
||||
Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.update_all marked_as_transfer: false
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
redirect_to transactions_path(updated_params)
|
||||
end
|
||||
|
||||
private
|
||||
def search_params
|
||||
cleaned_params = params.fetch(:q, {})
|
||||
.permit(
|
||||
:start_date, :end_date, :search, :amount,
|
||||
:amount_operator, accounts: [], account_ids: [],
|
||||
categories: [], merchants: [], types: [], tags: []
|
||||
)
|
||||
.to_h
|
||||
.compact_blank
|
||||
|
||||
def amount
|
||||
if nature.income?
|
||||
transaction_entry_params[:amount].to_d * -1
|
||||
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
|
||||
|
||||
cleaned_params
|
||||
end
|
||||
|
||||
def store_params!
|
||||
if should_restore_params?
|
||||
params_to_restore = {}
|
||||
|
||||
params_to_restore[:q] = stored_params["q"].presence || default_params[:q]
|
||||
params_to_restore[:page] = stored_params["page"].presence || default_params[:page]
|
||||
params_to_restore[:per_page] = stored_params["per_page"].presence || default_params[:per_page]
|
||||
|
||||
redirect_to transactions_path(params_to_restore)
|
||||
else
|
||||
transaction_entry_params[:amount].to_d
|
||||
Current.session.update!(
|
||||
prev_transaction_page_params: {
|
||||
q: search_params,
|
||||
page: params[:page],
|
||||
per_page: params[:per_page]
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def nature
|
||||
params[:account_entry][:nature].to_s.inquiry
|
||||
def should_restore_params?
|
||||
request.query_parameters.blank? && (stored_params["q"].present? || stored_params["page"].present? || stored_params["per_page"].present?)
|
||||
end
|
||||
|
||||
def bulk_delete_params
|
||||
params.require(:bulk_delete).permit(entry_ids: [])
|
||||
def stored_params
|
||||
Current.session.prev_transaction_page_params
|
||||
end
|
||||
|
||||
def bulk_update_params
|
||||
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.fetch(:q, {})
|
||||
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
|
||||
end
|
||||
|
||||
def transaction_entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: [ :category_id ])
|
||||
.with_defaults(entryable_type: "Account::Transaction", entryable_attributes: {})
|
||||
def default_params
|
||||
{
|
||||
q: {},
|
||||
page: 1,
|
||||
per_page: 50
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
72
app/controllers/transfers_controller.rb
Normal file
72
app/controllers/transfers_controller.rb
Normal file
@@ -0,0 +1,72 @@
|
||||
class TransfersController < ApplicationController
|
||||
before_action :set_transfer, only: %i[destroy show update]
|
||||
|
||||
def new
|
||||
@transfer = Transfer.new
|
||||
end
|
||||
|
||||
def show
|
||||
@categories = Current.family.categories.expenses
|
||||
end
|
||||
|
||||
def create
|
||||
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
|
||||
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
|
||||
|
||||
@transfer = Transfer.from_accounts(
|
||||
from_account: from_account,
|
||||
to_account: to_account,
|
||||
date: transfer_params[:date],
|
||||
amount: transfer_params[:amount].to_d
|
||||
)
|
||||
|
||||
if @transfer.save
|
||||
@transfer.sync_account_later
|
||||
|
||||
flash[:notice] = t(".success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transactions_path }
|
||||
redirect_target_url = request.referer || transactions_path
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if transfer_update_params[:status] == "rejected"
|
||||
@transfer.reject!
|
||||
elsif transfer_update_params[:status] == "confirmed"
|
||||
@transfer.confirm!
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transfer.destroy!
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def set_transfer
|
||||
@transfer = Transfer.find(params[:id])
|
||||
|
||||
raise ActiveRecord::RecordNotFound unless @transfer.belongs_to_family?(Current.family)
|
||||
end
|
||||
|
||||
def transfer_params
|
||||
params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
|
||||
end
|
||||
|
||||
def transfer_update_params
|
||||
params.require(:transfer).permit(:notes, :status, :category_id)
|
||||
end
|
||||
end
|
||||
@@ -4,10 +4,26 @@ class UsersController < ApplicationController
|
||||
def update
|
||||
@user = Current.user
|
||||
|
||||
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
|
||||
@user.profile_image.purge if should_purge_profile_image?
|
||||
if email_changed?
|
||||
if @user.initiate_email_change(user_params[:email])
|
||||
if Rails.application.config.app_mode.self_hosted? && !Setting.require_email_confirmation
|
||||
handle_redirect(t(".success"))
|
||||
else
|
||||
redirect_to settings_profile_path, notice: t(".email_change_initiated")
|
||||
end
|
||||
else
|
||||
error_message = @user.errors.any? ? @user.errors.full_messages.to_sentence : t(".email_change_failed")
|
||||
redirect_to settings_profile_path, alert: error_message
|
||||
end
|
||||
else
|
||||
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
|
||||
@user.profile_image.purge if should_purge_profile_image?
|
||||
|
||||
handle_redirect(t(".success"))
|
||||
respond_to do |format|
|
||||
format.html { handle_redirect(t(".success")) }
|
||||
format.json { head :ok }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -38,10 +54,14 @@ class UsersController < ApplicationController
|
||||
user_params[:profile_image].blank?
|
||||
end
|
||||
|
||||
def email_changed?
|
||||
user_params[:email].present? && user_params[:email] != @user.email
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(
|
||||
:first_name, :last_name, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
|
||||
family_attributes: [ :name, :currency, :country, :locale, :date_format, :id ]
|
||||
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar,
|
||||
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ]
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
class WebhooksController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token, only: [ :stripe ]
|
||||
skip_before_action :verify_authenticity_token
|
||||
skip_authentication
|
||||
|
||||
def plaid
|
||||
webhook_body = request.body.read
|
||||
plaid_verification_header = request.headers["Plaid-Verification"]
|
||||
|
||||
client = Provider::Plaid.new(Rails.application.config.plaid, region: :us)
|
||||
|
||||
client.validate_webhook!(plaid_verification_header, webhook_body)
|
||||
client.process_webhook(webhook_body)
|
||||
|
||||
render json: { received: true }, status: :ok
|
||||
rescue => error
|
||||
Sentry.capture_exception(error)
|
||||
render json: { error: "Invalid webhook: #{error.message}" }, status: :bad_request
|
||||
end
|
||||
|
||||
def plaid_eu
|
||||
webhook_body = request.body.read
|
||||
plaid_verification_header = request.headers["Plaid-Verification"]
|
||||
|
||||
client = Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu)
|
||||
|
||||
client.validate_webhook!(plaid_verification_header, webhook_body)
|
||||
client.process_webhook(webhook_body)
|
||||
|
||||
render json: { received: true }, status: :ok
|
||||
rescue => error
|
||||
Sentry.capture_exception(error)
|
||||
render json: { error: "Invalid webhook: #{error.message}" }, status: :bad_request
|
||||
end
|
||||
|
||||
def stripe
|
||||
webhook_body = request.body.read
|
||||
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
|
||||
@@ -21,10 +51,12 @@ class WebhooksController < ApplicationController
|
||||
Rails.logger.info "Unhandled event type: #{event.type}"
|
||||
end
|
||||
|
||||
rescue JSON::ParserError
|
||||
rescue JSON::ParserError => error
|
||||
Sentry.capture_exception(error)
|
||||
render json: { error: "Invalid payload" }, status: :bad_request
|
||||
return
|
||||
rescue Stripe::SignatureVerificationError
|
||||
rescue Stripe::SignatureVerificationError => error
|
||||
Sentry.capture_exception(error)
|
||||
render json: { error: "Invalid signature" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
module Account::CashesHelper
|
||||
def brokerage_cash(account)
|
||||
currency = Money::Currency.new(account.currency)
|
||||
|
||||
account.holdings.build \
|
||||
date: Date.current,
|
||||
qty: account.balance,
|
||||
price: 1,
|
||||
amount: account.balance,
|
||||
currency: account.currency,
|
||||
security: Security.new(ticker: currency.iso_code, name: currency.name)
|
||||
end
|
||||
end
|
||||
@@ -1,36 +1,22 @@
|
||||
module Account::EntriesHelper
|
||||
def permitted_entryable_partial_path(entry, relative_partial_path)
|
||||
"account/entries/entryables/#{permitted_entryable_key(entry)}/#{relative_partial_path}"
|
||||
end
|
||||
|
||||
def unconfirmed_transfer?(entry)
|
||||
entry.marked_as_transfer? && entry.transfer.nil?
|
||||
end
|
||||
|
||||
def transfer_entries(entries)
|
||||
transfers = entries.select { |e| e.transfer_id.present? }
|
||||
transfers.map(&:transfer).uniq
|
||||
end
|
||||
|
||||
def entries_by_date(entries, selectable: true, totals: false)
|
||||
def entries_by_date(entries, totals: false)
|
||||
entries.group_by(&:date).map do |date, grouped_entries|
|
||||
# Valuations always go first, then sort by created_at desc
|
||||
sorted_entries = grouped_entries.sort_by do |entry|
|
||||
[ entry.account_valuation? ? 0 : 1, -entry.created_at.to_i ]
|
||||
end
|
||||
|
||||
content = capture do
|
||||
yield sorted_entries
|
||||
yield grouped_entries
|
||||
end
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: sorted_entries, content:, selectable:, totals: }
|
||||
end.join.html_safe
|
||||
next if content.blank?
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, totals: }
|
||||
end.compact.join.html_safe
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permitted_entryable_key(entry)
|
||||
permitted_entryable_paths = %w[transaction valuation trade]
|
||||
entry.entryable_name_short.presence_in(permitted_entryable_paths)
|
||||
end
|
||||
def entry_name_detailed(entry)
|
||||
[
|
||||
entry.date,
|
||||
format_money(entry.amount_money),
|
||||
entry.account.name,
|
||||
entry.display_name
|
||||
].join(" • ")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
module Account::TransfersHelper
|
||||
end
|
||||
@@ -1,73 +1,6 @@
|
||||
module AccountsHelper
|
||||
def period_label(period)
|
||||
return "since account creation" if period.date_range.begin.nil?
|
||||
start_date, end_date = period.date_range.first, period.date_range.last
|
||||
|
||||
return "Starting from #{start_date.strftime('%b %d, %Y')}" if end_date.nil?
|
||||
return "Ending at #{end_date.strftime('%b %d, %Y')}" if start_date.nil?
|
||||
|
||||
days_apart = (end_date - start_date).to_i
|
||||
|
||||
case days_apart
|
||||
when 1
|
||||
"vs. yesterday"
|
||||
when 7
|
||||
"vs. last week"
|
||||
when 30, 31
|
||||
"vs. last month"
|
||||
when 365, 366
|
||||
"vs. last year"
|
||||
else
|
||||
"from #{start_date.strftime('%b %d, %Y')} to #{end_date.strftime('%b %d, %Y')}"
|
||||
end
|
||||
end
|
||||
|
||||
def summary_card(title:, &block)
|
||||
content = capture(&block)
|
||||
render "accounts/summary_card", title: title, content: content
|
||||
end
|
||||
|
||||
def to_accountable_title(accountable)
|
||||
accountable.model_name.human
|
||||
end
|
||||
|
||||
def accountable_text_class(accountable_type)
|
||||
class_mapping(accountable_type)[:text]
|
||||
end
|
||||
|
||||
def accountable_fill_class(accountable_type)
|
||||
class_mapping(accountable_type)[:fill]
|
||||
end
|
||||
|
||||
def accountable_bg_class(accountable_type)
|
||||
class_mapping(accountable_type)[:bg]
|
||||
end
|
||||
|
||||
def accountable_bg_transparent_class(accountable_type)
|
||||
class_mapping(accountable_type)[:bg_transparent]
|
||||
end
|
||||
|
||||
def accountable_color(accountable_type)
|
||||
class_mapping(accountable_type)[:hex]
|
||||
end
|
||||
|
||||
def account_groups(period: nil)
|
||||
assets, liabilities = Current.family.accounts.active.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
|
||||
[ assets.children.sort_by(&:name), liabilities.children.sort_by(&:name) ].flatten
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def class_mapping(accountable_type)
|
||||
{
|
||||
"CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
|
||||
"Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
|
||||
"OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
|
||||
"Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
|
||||
"Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
|
||||
"OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
|
||||
"Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
|
||||
"Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
|
||||
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
module ApplicationHelper
|
||||
include Pagy::Frontend
|
||||
|
||||
def date_format_options
|
||||
[
|
||||
[ "DD-MM-YYYY", "%d-%m-%Y" ],
|
||||
[ "MM-DD-YYYY", "%m-%d-%Y" ],
|
||||
[ "YYYY-MM-DD", "%Y-%m-%d" ],
|
||||
[ "DD/MM/YYYY", "%d/%m/%Y" ],
|
||||
[ "YYYY/MM/DD", "%Y/%m/%d" ],
|
||||
[ "MM/DD/YYYY", "%m/%d/%Y" ],
|
||||
[ "D/MM/YYYY", "%e/%m/%Y" ],
|
||||
[ "YYYY.MM.DD", "%Y.%m.%d" ]
|
||||
]
|
||||
def icon(key, size: "md", color: "current")
|
||||
render partial: "shared/icon", locals: { key:, size:, color: }
|
||||
end
|
||||
|
||||
# Convert alpha (0-1) to 8-digit hex (00-FF)
|
||||
def hex_with_alpha(hex, alpha)
|
||||
alpha_hex = (alpha * 255).round.to_s(16).rjust(2, "0")
|
||||
"#{hex}#{alpha_hex}"
|
||||
end
|
||||
|
||||
def title(page_title)
|
||||
@@ -22,6 +19,10 @@ module ApplicationHelper
|
||||
content_for(:header_title) { page_title }
|
||||
end
|
||||
|
||||
def header_description(page_description)
|
||||
content_for(:header_description) { page_description }
|
||||
end
|
||||
|
||||
def family_notifications_stream
|
||||
turbo_stream_from [ Current.family, :notifications ] if Current.family
|
||||
end
|
||||
@@ -61,28 +62,18 @@ module ApplicationHelper
|
||||
# <div>Content here</div>
|
||||
# <% end %>
|
||||
#
|
||||
def drawer(&block)
|
||||
def drawer(reload_on_close: false, &block)
|
||||
content = capture &block
|
||||
render partial: "shared/drawer", locals: { content: content }
|
||||
render partial: "shared/drawer", locals: { content:, reload_on_close: }
|
||||
end
|
||||
|
||||
def disclosure(title, &block)
|
||||
def disclosure(title, default_open: true, &block)
|
||||
content = capture &block
|
||||
render partial: "shared/disclosure", locals: { title: title, content: content }
|
||||
render partial: "shared/disclosure", locals: { title: title, content: content, open: default_open }
|
||||
end
|
||||
|
||||
def sidebar_link_to(name, path, options = {})
|
||||
is_current = current_page?(path) || (request.path.start_with?(path) && path != "/")
|
||||
|
||||
classes = [
|
||||
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm font-medium text-gray-500",
|
||||
(is_current ? "bg-white text-gray-900 shadow-xs border-alpha-black-50" : "hover:bg-gray-100 border-transparent")
|
||||
].compact.join(" ")
|
||||
|
||||
link_to path, **options.merge(class: classes), aria: { current: ("page" if current_page?(path)) } do
|
||||
concat(lucide_icon(options[:icon], class: "w-5 h-5")) if options[:icon]
|
||||
concat(name)
|
||||
end
|
||||
def page_active?(path)
|
||||
current_page?(path) || (request.path.start_with?(path) && path != "/")
|
||||
end
|
||||
|
||||
def mixed_hex_styles(hex)
|
||||
@@ -104,24 +95,6 @@ module ApplicationHelper
|
||||
uri.relative? ? uri.path : root_path
|
||||
end
|
||||
|
||||
def trend_styles(trend)
|
||||
fallback = { bg_class: "bg-gray-500/5", text_class: "text-gray-500", symbol: "", icon: "minus" }
|
||||
return fallback if trend.nil? || trend.direction.flat?
|
||||
|
||||
bg_class, text_class, symbol, icon = case trend.direction
|
||||
when "up"
|
||||
trend.favorable_direction.down? ? [ "bg-red-500/5", "text-red-500", "+", "arrow-up" ] : [ "bg-green-500/5", "text-green-500", "+", "arrow-up" ]
|
||||
when "down"
|
||||
trend.favorable_direction.down? ? [ "bg-green-500/5", "text-green-500", "-", "arrow-down" ] : [ "bg-red-500/5", "text-red-500", "-", "arrow-down" ]
|
||||
when "flat"
|
||||
[ "bg-gray-500/5", "text-gray-500", "", "minus" ]
|
||||
else
|
||||
raise ArgumentError, "Invalid trend direction: #{trend.direction}"
|
||||
end
|
||||
|
||||
{ bg_class: bg_class, text_class: text_class, symbol: symbol, icon: icon }
|
||||
end
|
||||
|
||||
# Wrapper around I18n.l to support custom date formats
|
||||
def format_date(object, format = :default, options = {})
|
||||
date = object.to_date
|
||||
@@ -138,23 +111,28 @@ module ApplicationHelper
|
||||
def format_money(number_or_money, options = {})
|
||||
return nil unless number_or_money
|
||||
|
||||
money = Money.new(number_or_money)
|
||||
options.reverse_merge!(money.format_options(I18n.locale))
|
||||
number_to_currency(money.amount, options)
|
||||
end
|
||||
|
||||
def format_money_without_symbol(number_or_money, options = {})
|
||||
return nil unless number_or_money
|
||||
|
||||
money = Money.new(number_or_money)
|
||||
options.reverse_merge!(money.format_options(I18n.locale))
|
||||
ActiveSupport::NumberHelper.number_to_delimited(money.amount.round(options[:precision] || 0), { delimiter: options[:delimiter], separator: options[:separator] })
|
||||
Money.new(number_or_money).format(options)
|
||||
end
|
||||
|
||||
def totals_by_currency(collection:, money_method:, separator: " | ", negate: false)
|
||||
collection.group_by(&:currency)
|
||||
.transform_values { |item| negate ? item.sum(&money_method) * -1 : item.sum(&money_method) }
|
||||
.transform_values { |item| calculate_total(item, money_method, negate) }
|
||||
.map { |_currency, money| format_money(money) }
|
||||
.join(separator)
|
||||
end
|
||||
|
||||
def show_super_admin_bar?
|
||||
if params[:admin].present?
|
||||
cookies.permanent[:admin] = params[:admin]
|
||||
end
|
||||
|
||||
cookies[:admin] == "true"
|
||||
end
|
||||
|
||||
private
|
||||
def calculate_total(item, money_method, negate)
|
||||
items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? }
|
||||
total = items.sum(&money_method)
|
||||
negate ? -total : total
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
module CategoriesHelper
|
||||
def null_category
|
||||
def transfer_category
|
||||
Category.new \
|
||||
name: "Uncategorized",
|
||||
color: Category::UNCATEGORIZED_COLOR
|
||||
name: "Transfer",
|
||||
color: Category::TRANSFER_COLOR,
|
||||
lucide_icon: "arrow-right-left"
|
||||
end
|
||||
|
||||
def payment_category
|
||||
Category.new \
|
||||
name: "Payment",
|
||||
color: Category::PAYMENT_COLOR,
|
||||
lucide_icon: "arrow-right"
|
||||
end
|
||||
|
||||
def trade_category
|
||||
Category.new \
|
||||
name: "Trade",
|
||||
color: Category::TRADE_COLOR
|
||||
end
|
||||
|
||||
def family_categories
|
||||
[ null_category ].concat(Current.family.categories.alphabetically)
|
||||
[ Category.uncategorized ].concat(Current.family.categories.alphabetically)
|
||||
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