1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-08 06:55:21 +02:00

Fix rubocop offenses

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Josh Pigford 2025-06-17 11:36:05 -05:00
parent 6c8e518df7
commit dbe05e3795
10 changed files with 245 additions and 73 deletions

172
CLAUDE.md Normal file
View file

@ -0,0 +1,172 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Development Commands
### Development Server
- `bin/dev` - Start development server (Rails, Sidekiq, Tailwind CSS watcher)
- `bin/rails server` - Start Rails server only
- `bin/rails console` - Open Rails console
### Testing
- `bin/rails test` - Run all tests
- `bin/rails test:db` - Run tests with database reset
- `bin/rails test:system` - Run system tests only
- `bin/rails test test/models/account_test.rb` - Run specific test file
- `bin/rails test test/models/account_test.rb:42` - Run specific test at line
### Linting & Formatting
- `bin/rubocop` - Run Ruby linter
- `npm run lint` - Check JavaScript/TypeScript code
- `npm run lint:fix` - Fix JavaScript/TypeScript issues
- `npm run format` - Format JavaScript/TypeScript code
- `bin/brakeman` - Run security analysis
### Database
- `bin/rails db:prepare` - Create and migrate database
- `bin/rails db:migrate` - Run pending migrations
- `bin/rails db:rollback` - Rollback last migration
- `bin/rails db:seed` - Load seed data
### Setup
- `bin/setup` - Initial project setup (installs dependencies, prepares database)
## General Development Rules
### Authentication Context
- Use `Current.user` for the current user. Do NOT use `current_user`.
- Use `Current.family` for the current family. Do NOT use `current_family`.
### Development Guidelines
- Prior to generating any code, carefully read the project conventions and guidelines
- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development
- Do not run `rails server` in your responses
- Do not run `touch tmp/restart.txt`
- Do not run `rails credentials`
- Do not automatically run migrations
## High-Level Architecture
### Application Modes
The Maybe app runs in two distinct modes:
- **Managed**: The Maybe team operates and manages servers for users (Rails.application.config.app_mode = "managed")
- **Self Hosted**: Users host the Maybe app on their own infrastructure, typically through Docker Compose (Rails.application.config.app_mode = "self_hosted")
### Core Domain Model
The application is built around financial data management with these key relationships:
- **User** → has many **Accounts** → has many **Transactions**
- **Account** types: checking, savings, credit cards, investments, crypto, loans, properties
- **Transaction** → belongs to **Category**, can have **Tags** and **Rules**
- **Investment accounts** → have **Holdings** → track **Securities** via **Trades**
### API Architecture
The application provides both internal and external APIs:
- Internal API: Controllers serve JSON via Turbo for SPA-like interactions
- External API: `/api/v1/` namespace with Doorkeeper OAuth and API key authentication
- API responses use Jbuilder templates for JSON rendering
- Rate limiting via Rack Attack with configurable limits per API key
### Sync & Import System
Two primary data ingestion methods:
1. **Plaid Integration**: Real-time bank account syncing
- `PlaidItem` manages connections
- `Sync` tracks sync operations
- Background jobs handle data updates
2. **CSV Import**: Manual data import with mapping
- `Import` manages import sessions
- Supports transaction and balance imports
- Custom field mapping with transformation rules
### Background Processing
Sidekiq handles asynchronous tasks:
- Account syncing (`SyncAccountsJob`)
- Import processing (`ImportDataJob`)
- AI chat responses (`CreateChatResponseJob`)
- Scheduled maintenance via sidekiq-cron
### Frontend Architecture
- **Hotwire Stack**: Turbo + Stimulus for reactive UI without heavy JavaScript
- **ViewComponents**: Reusable UI components in `app/components/`
- **Stimulus Controllers**: Handle interactivity, organized alongside components
- **Charts**: D3.js for financial visualizations (time series, donut, sankey)
- **Styling**: Tailwind CSS v4.x with custom design system
- Design system defined in `app/assets/tailwind/maybe-design-system.css`
- Always use functional tokens (e.g., `text-primary` not `text-white`)
- Prefer semantic HTML elements over JS components
- Use `icon` helper for icons, never `lucide_icon` directly
### Multi-Currency Support
- All monetary values stored in base currency (user's primary currency)
- Exchange rates fetched from Synth API
- `Money` objects handle currency conversion and formatting
- Historical exchange rates for accurate reporting
### Security & Authentication
- Session-based auth for web users
- API authentication via:
- OAuth2 (Doorkeeper) for third-party apps
- API keys with JWT tokens for direct API access
- Scoped permissions system for API access
- Strong parameters and CSRF protection throughout
### Key Service Objects & Patterns
- **Query Objects**: Complex database queries isolated in `app/queries/`
- **Service Objects**: Business logic in `app/services/`
- **Form Objects**: Complex forms with validation
- **Concerns**: Shared functionality across models/controllers
- **Jobs**: Background processing logic
### Testing Philosophy
- Comprehensive test coverage using Rails' built-in Minitest
- Fixtures for test data (avoid FactoryBot)
- Keep fixtures minimal (2-3 per model for base cases)
- VCR for external API testing
- System tests for critical user flows (use sparingly)
- Test helpers in `test/support/` for common scenarios
- Only test critical code paths that significantly increase confidence
- Write tests as you go, when required
### Performance Considerations
- Database queries optimized with proper indexes
- N+1 queries prevented via includes/joins
- Background jobs for heavy operations
- Caching strategies for expensive calculations
- Turbo Frames for partial page updates
### Development Workflow
- Feature branches merged to `main`
- Docker support for consistent environments
- Environment variables via `.env` files
- Lookbook for component development (`/lookbook`)
- Letter Opener for email preview in development
## Project Conventions
### Convention 1: Minimize Dependencies
- Push Rails to its limits before adding new dependencies
- When adding dependencies, favor old and reliable over new and flashy
- Strong technical or business reason required for new dependencies
### Convention 2: POROs and Concerns over Service Objects
- "Skinny controller, fat models" convention
- Everything in `app/models/` folder, avoid separate folders like `app/services/`
- Use Rails concerns for better organization (can be one-off concerns)
- Models should answer questions about themselves (e.g., `account.balance_series`)
### Convention 3: Leverage Hotwire and Server-Side Solutions
- Native HTML preferred over JS components (e.g., `<dialog>`, `<details>`)
- Use Turbo frames to break up pages
- Leverage query params for state over local storage
- Format values server-side, pass to Stimulus for display only
- Client-side code only where it truly shines (e.g., bulk selections)
### Convention 4: Optimize for Simplicity and Clarity
- Prioritize good OOP domain design over performance
- Only focus on performance in critical/global areas
- Be mindful of N+1 queries and large data payloads
### Convention 5: ActiveRecord for Complex Validations, DB for Simple Ones
- Enforce null checks, unique indexes in the DB
- ActiveRecord validations for convenience in forms
- Complex validations and business logic in ActiveRecord

View file

@ -30,7 +30,7 @@ class Api::V1::AccountsController < Api::V1::BaseController
error: "internal_server_error", error: "internal_server_error",
message: "Error: #{e.message}" message: "Error: #{e.message}"
}, status: :internal_server_error }, status: :internal_server_error
end end
private private

View file

@ -47,11 +47,11 @@ class Api::V1::BaseController < ApplicationController
return false unless request.headers["Authorization"].present? return false unless request.headers["Authorization"].present?
# Manually verify the token (bypassing doorkeeper_authorize! which had scope issues) # Manually verify the token (bypassing doorkeeper_authorize! which had scope issues)
token_string = request.authorization&.split(' ')&.last token_string = request.authorization&.split(" ")&.last
access_token = Doorkeeper::AccessToken.by_token(token_string) access_token = Doorkeeper::AccessToken.by_token(token_string)
# Check token validity and scope (read_write includes read access) # Check token validity and scope (read_write includes read access)
has_sufficient_scope = access_token&.scopes&.include?('read') || access_token&.scopes&.include?('read_write') has_sufficient_scope = access_token&.scopes&.include?("read") || access_token&.scopes&.include?("read_write")
unless access_token && !access_token.expired? && has_sufficient_scope unless access_token && !access_token.expired? && has_sufficient_scope
render_json({ error: "unauthorized", message: "Access token is invalid, expired, or missing required scope" }, status: :unauthorized) render_json({ error: "unauthorized", message: "Access token is invalid, expired, or missing required scope" }, status: :unauthorized)

View file

@ -4,9 +4,9 @@ class Api::V1::TransactionsController < Api::V1::BaseController
include Pagy::Backend include Pagy::Backend
# Ensure proper scope authorization for read vs write access # Ensure proper scope authorization for read vs write access
before_action :ensure_read_scope, only: [:index, :show] before_action :ensure_read_scope, only: [ :index, :show ]
before_action :ensure_write_scope, only: [:create, :update, :destroy] before_action :ensure_write_scope, only: [ :create, :update, :destroy ]
before_action :set_transaction, only: [:show, :update, :destroy] before_action :set_transaction, only: [ :show, :update, :destroy ]
def index def index
family = current_resource_owner.family family = current_resource_owner.family
@ -71,7 +71,7 @@ class Api::V1::TransactionsController < Api::V1::BaseController
render json: { render json: {
error: "validation_failed", error: "validation_failed",
message: "Account ID is required", message: "Account ID is required",
errors: ["Account ID is required"] errors: [ "Account ID is required" ]
}, status: :unprocessable_entity }, status: :unprocessable_entity
return return
end end
@ -102,7 +102,7 @@ class Api::V1::TransactionsController < Api::V1::BaseController
error: "internal_server_error", error: "internal_server_error",
message: "Error: #{e.message}" message: "Error: #{e.message}"
}, status: :internal_server_error }, status: :internal_server_error
end end
def update def update
if @entry.update(entry_params_for_update) if @entry.update(entry_params_for_update)
@ -202,22 +202,22 @@ class Api::V1::TransactionsController < Api::V1::BaseController
# Date range filtering # Date range filtering
if params[:start_date].present? if params[:start_date].present?
query = query.joins(:entry).where('entries.date >= ?', Date.parse(params[:start_date])) query = query.joins(:entry).where("entries.date >= ?", Date.parse(params[:start_date]))
end end
if params[:end_date].present? if params[:end_date].present?
query = query.joins(:entry).where('entries.date <= ?', Date.parse(params[:end_date])) query = query.joins(:entry).where("entries.date <= ?", Date.parse(params[:end_date]))
end end
# Amount filtering # Amount filtering
if params[:min_amount].present? if params[:min_amount].present?
min_amount = params[:min_amount].to_f min_amount = params[:min_amount].to_f
query = query.joins(:entry).where('entries.amount >= ?', min_amount) query = query.joins(:entry).where("entries.amount >= ?", min_amount)
end end
if params[:max_amount].present? if params[:max_amount].present?
max_amount = params[:max_amount].to_f max_amount = params[:max_amount].to_f
query = query.joins(:entry).where('entries.amount <= ?', max_amount) query = query.joins(:entry).where("entries.amount <= ?", max_amount)
end end
# Tag filtering # Tag filtering
@ -229,10 +229,10 @@ class Api::V1::TransactionsController < Api::V1::BaseController
# Transaction type filtering (income/expense) # Transaction type filtering (income/expense)
if params[:type].present? if params[:type].present?
case params[:type].downcase case params[:type].downcase
when 'income' when "income"
query = query.joins(:entry).where('entries.amount < 0') query = query.joins(:entry).where("entries.amount < 0")
when 'expense' when "expense"
query = query.joins(:entry).where('entries.amount > 0') query = query.joins(:entry).where("entries.amount > 0")
end end
end end
@ -245,10 +245,10 @@ class Api::V1::TransactionsController < Api::V1::BaseController
query.joins(:entry) query.joins(:entry)
.left_joins(:merchant) .left_joins(:merchant)
.where( .where(
'entries.name ILIKE ? OR entries.notes ILIKE ? OR merchants.name ILIKE ?', "entries.name ILIKE ? OR entries.notes ILIKE ? OR merchants.name ILIKE ?",
search_term, search_term, search_term search_term, search_term, search_term
) )
end end
def transaction_params def transaction_params
params.require(:transaction).permit( params.require(:transaction).permit(
@ -264,7 +264,7 @@ class Api::V1::TransactionsController < Api::V1::BaseController
amount: calculate_signed_amount, amount: calculate_signed_amount,
currency: transaction_params[:currency] || current_resource_owner.family.currency, currency: transaction_params[:currency] || current_resource_owner.family.currency,
notes: transaction_params[:notes], notes: transaction_params[:notes],
entryable_type: 'Transaction', entryable_type: "Transaction",
entryable_attributes: { entryable_attributes: {
category_id: transaction_params[:category_id], category_id: transaction_params[:category_id],
merchant_id: transaction_params[:merchant_id], merchant_id: transaction_params[:merchant_id],
@ -301,9 +301,9 @@ class Api::V1::TransactionsController < Api::V1::BaseController
nature = transaction_params[:nature] nature = transaction_params[:nature]
case nature&.downcase case nature&.downcase
when 'income', 'inflow' when "income", "inflow"
-amount.abs # Income is negative -amount.abs # Income is negative
when 'expense', 'outflow' when "expense", "outflow"
amount.abs # Expense is positive amount.abs # Expense is positive
else else
amount # Use as provided amount # Use as provided

View file

@ -54,7 +54,7 @@ class Settings::ApiKeysController < ApplicationController
# Convert single scope value to array for storage # Convert single scope value to array for storage
permitted_params = params.require(:api_key).permit(:name, :scopes) permitted_params = params.require(:api_key).permit(:name, :scopes)
if permitted_params[:scopes].present? if permitted_params[:scopes].present?
permitted_params[:scopes] = [permitted_params[:scopes]] permitted_params[:scopes] = [ permitted_params[:scopes] ]
end end
permitted_params permitted_params
end end

View file

@ -59,7 +59,7 @@ class ApiRateLimiter
{ {
current_count: current_count, current_count: current_count,
rate_limit: rate_limit, rate_limit: rate_limit,
remaining: [rate_limit - current_count, 0].max, remaining: [ rate_limit - current_count, 0 ].max,
reset_time: reset_time, reset_time: reset_time,
tier: determine_tier tier: determine_tier
} }

View file

@ -403,7 +403,7 @@ class Api::V1::BaseControllerTest < ActionDispatch::IntegrationTest
other_api_key = ApiKey.create!( other_api_key = ApiKey.create!(
user: @user, user: @user,
name: "Other Test API Key", name: "Other Test API Key",
scopes: ["read"], scopes: [ "read" ],
key: "other_test_key_for_rate_limiting" key: "other_test_key_for_rate_limiting"
) )

View file

@ -141,7 +141,7 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest
} }
} }
assert_difference('@account.entries.count', 1) do assert_difference("@account.entries.count", 1) do
post api_v1_transactions_url, post api_v1_transactions_url,
params: transaction_params, params: transaction_params,
headers: api_headers(@api_key) headers: api_headers(@api_key)
@ -242,14 +242,14 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest
) )
transaction_to_delete = entry_to_delete.transaction transaction_to_delete = entry_to_delete.transaction
assert_difference('@account.entries.count', -1) do assert_difference("@account.entries.count", -1) do
delete api_v1_transaction_url(transaction_to_delete), headers: api_headers(@api_key) delete api_v1_transaction_url(transaction_to_delete), headers: api_headers(@api_key)
end end
assert_response :success assert_response :success
response_data = JSON.parse(response.body) response_data = JSON.parse(response.body)
assert response_data.key?("message") assert response_data.key?("message")
end end
test "should reject destroy with read-only API key" do test "should reject destroy with read-only API key" do
delete api_v1_transaction_url(@transaction), headers: api_headers(@read_only_api_key) delete api_v1_transaction_url(@transaction), headers: api_headers(@read_only_api_key)

View file

@ -6,7 +6,7 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest
@api_key = ApiKey.create!( @api_key = ApiKey.create!(
user: @user, user: @user,
name: "Test API Key", name: "Test API Key",
scopes: ["read"], scopes: [ "read" ],
key: "test_key_for_usage" key: "test_key_for_usage"
) )
@ -34,7 +34,7 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest
# Check API key information # Check API key information
assert_equal "Test API Key", response_body["api_key"]["name"] assert_equal "Test API Key", response_body["api_key"]["name"]
assert_equal ["read"], response_body["api_key"]["scopes"] assert_equal [ "read" ], response_body["api_key"]["scopes"]
assert_not_nil response_body["api_key"]["last_used_at"] assert_not_nil response_body["api_key"]["last_used_at"]
assert_not_nil response_body["api_key"]["created_at"] assert_not_nil response_body["api_key"]["created_at"]

View file

@ -6,7 +6,7 @@ class ApiRateLimiterTest < ActiveSupport::TestCase
@api_key = ApiKey.create!( @api_key = ApiKey.create!(
user: @user, user: @user,
name: "Test API Key", name: "Test API Key",
scopes: ["read"], scopes: [ "read" ],
key: "test_key_123" key: "test_key_123"
) )
@rate_limiter = ApiRateLimiter.new(@api_key) @rate_limiter = ApiRateLimiter.new(@api_key)
@ -99,7 +99,7 @@ class ApiRateLimiterTest < ActiveSupport::TestCase
other_api_key = ApiKey.create!( other_api_key = ApiKey.create!(
user: @user, user: @user,
name: "Other API Key", name: "Other API Key",
scopes: ["read_write"], scopes: [ "read_write" ],
key: "other_key_456" key: "other_key_456"
) )