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

Merge branch 'main' into multi-currency-transfer

This commit is contained in:
Zach Gollwitzer 2025-06-25 15:19:35 -04:00 committed by GitHub
commit 9613bacabe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
138 changed files with 8800 additions and 723 deletions

View file

@ -66,54 +66,7 @@ All code should maximize readability and simplicity.
- Example 1: be mindful of loading large data payloads in global layouts
- Example 2: Avoid N+1 queries
### Convention 5: Use Minitest + Fixtures for testing, minimize fixtures
Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability.
- Always use Minitest and fixtures for testing.
- Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed.
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb)
- Take a minimal approach to testing—only test the absolutely critical code paths that will significantly increase developer confidence
#### Convention 5a: Write minimal, effective tests
- Use system tests sparingly as they increase the time to complete the test suite
- Only write tests for critical and important code paths
- Write tests as you go, when required
- Take a practical approach to testing. Tests are effective when their presence _significantly increases confidence in the codebase_.
Below are examples of necessary vs. unnecessary tests:
```rb
# GOOD!!
# Necessary test - in this case, we're testing critical domain business logic
test "syncs balances" do
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
@account.expects(:start_date).returns(2.days.ago.to_date)
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
[
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
]
)
assert_difference "@account.balances.count", 2 do
Balance::Syncer.new(@account, strategy: :forward).sync_balances
end
end
# BAD!!
# Unnecessary test - in this case, this is simply testing ActiveRecord's functionality
test "saves balance" do
balance_record = Balance.new(balance: 100, currency: "USD")
assert balance_record.save
end
```
### Convention 6: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
### Convention 5: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
- Enforce `null` checks, unique indexes, and other simple validations in the DB
- ActiveRecord validations _may_ mirror the DB level ones, but not 100% necessary. These are for convenience when error handling in forms. Always prefer client-side form validation when possible.

View file

@ -0,0 +1,64 @@
---
description:
globs:
alwaysApply: false
---
This rule describes how to write Stimulus controllers.
- **Use declarative actions, not imperative event listeners**
- Instead of assigning a Stimulus target and binding it to an event listener in the initializer, always write Controllers + ERB views declaratively by using Stimulus actions in ERB to call methods in the Stimulus JS controller. Below are good vs. bad code.
BAD code:
```js
// BAD!!!! DO NOT DO THIS!!
// Imperative - controller does all the work
export default class extends Controller {
static targets = ["button", "content"]
connect() {
this.buttonTarget.addEventListener("click", this.toggle.bind(this))
}
toggle() {
this.contentTarget.classList.toggle("hidden")
this.buttonTarget.textContent = this.contentTarget.classList.contains("hidden") ? "Show" : "Hide"
}
}
```
GOOD code:
```erb
<!-- Declarative - HTML declares what happens -->
<div data-controller="toggle">
<button data-action="click->toggle#toggle" data-toggle-target="button">Show</button>
<div data-toggle-target="content" class="hidden">Hello World!</div>
</div>
```
```js
// Declarative - controller just responds
export default class extends Controller {
static targets = ["button", "content"]
toggle() {
this.contentTarget.classList.toggle("hidden")
this.buttonTarget.textContent = this.contentTarget.classList.contains("hidden") ? "Show" : "Hide"
}
}
```
- **Keep Stimulus controllers lightweight and simple**
- Always aim for less than 7 controller targets. Any more is a sign of too much complexity.
- Use private methods and expose a clear public API
- **Keep Stimulus controllers focused on what they do best**
- Domain logic does NOT belong in a Stimulus controller
- Stimulus controllers should aim for a single responsibility, or a group of highly related responsibilities
- Make good use of Stimulus's callbacks, actions, targets, values, and classes
- **Component controllers should not be used outside the component**
- If a Stimulus controller is in the app/components directory, it should only be used in its component view. It should not be used anywhere in app/views.

87
.cursor/rules/testing.mdc Normal file
View file

@ -0,0 +1,87 @@
---
description:
globs: test/**
alwaysApply: false
---
Use this rule to learn how to write tests for the Maybe codebase.
Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability.
- **General testing rules**
- Always use Minitest and fixtures for testing, NEVER rspec or factories
- Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed.
- For tests that require a large number of fixture records to be created, use Rails helpers to help create the records needed for the test, then inline the creation. For example, [entries_test_helper.rb](mdc:test/support/entries_test_helper.rb) provides helpers to easily do this.
- **Write minimal, effective tests**
- Use system tests sparingly as they increase the time to complete the test suite
- Only write tests for critical and important code paths
- Write tests as you go, when required
- Take a practical approach to testing. Tests are effective when their presence _significantly increases confidence in the codebase_.
Below are examples of necessary vs. unnecessary tests:
```rb
# GOOD!!
# Necessary test - in this case, we're testing critical domain business logic
test "syncs balances" do
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
@account.expects(:start_date).returns(2.days.ago.to_date)
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
[
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
]
)
assert_difference "@account.balances.count", 2 do
Balance::Syncer.new(@account, strategy: :forward).sync_balances
end
end
# BAD!!
# Unnecessary test - in this case, this is simply testing ActiveRecord's functionality
test "saves balance" do
balance_record = Balance.new(balance: 100, currency: "USD")
assert balance_record.save
end
```
- **Test boundaries correctly**
- Distinguish between commands and query methods. Test output of query methods; test that commands were called with the correct params. See an example below:
```rb
class ExampleClass
def do_something
result = 2 + 2
CustomEventProcessor.process_result(result)
result
end
end
class ExampleClass < ActiveSupport::TestCase
test "boundaries are tested correctly" do
result = ExampleClass.new.do_something
# GOOD - we're only testing that the command was received, not internal implementation details
# The actual tests for CustomEventProcessor belong in a different test suite!
CustomEventProcessor.expects(:process_result).with(4).once
# GOOD - we're testing the implementation of ExampleClass inside its own test suite
assert_equal 4, result
end
end
```
- Never test the implementation details of one class in another classes test suite
- **Stubs and mocks**
- Use `mocha` gem
- Always prefer `OpenStruct` when creating mock instances, or in complex cases, a mock class
- Only mock what's necessary. If you're not testing return values, don't mock a return value.

View file

@ -0,0 +1,100 @@
---
description:
globs: app/views/**,app/javascript/**,app/components/**/*.js
alwaysApply: false
---
Use this rule to learn how to write ERB views, partials, and Stimulus controllers should be incorporated into them.
- **Component vs. Partial Decision Making**
- **Use ViewComponents when:**
- Element has complex logic or styling patterns
- Element will be reused across multiple views/contexts
- Element needs structured styling with variants/sizes (like buttons, badges)
- Element requires interactive behavior or Stimulus controllers
- Element has configurable slots or complex APIs
- Element needs accessibility features or ARIA support
- **Use Partials when:**
- Element is primarily static HTML with minimal logic
- Element is used in only one or few specific contexts
- Element is simple template content (like CTAs, static sections)
- Element doesn't need variants, sizes, or complex configuration
- Element is more about content organization than reusable functionality
- **Prefer components over partials**
- If there is a component available for the use case in app/components, use it
- If there is no component, look for a partial
- If there is no partial, decide between component or partial based on the criteria above
- **Examples of Component vs. Partial Usage**
```erb
<%# Component: Complex, reusable with variants and interactivity %>
<%= render DialogComponent.new(variant: :drawer) do |dialog| %>
<% dialog.with_header(title: "Account Settings") %>
<% dialog.with_body { "Dialog content here" } %>
<% end %>
<%# Component: Interactive with complex styling options %>
<%= render ButtonComponent.new(text: "Save Changes", variant: "primary", confirm: "Are you sure?") %>
<%# Component: Reusable with variants %>
<%= render FilledIconComponent.new(icon: "credit-card", variant: :surface) %>
<%# Partial: Static template content %>
<%= render "shared/logo" %>
<%# Partial: Simple, context-specific content with basic styling %>
<%= render "shared/trend_change", trend: @account.trend, comparison_label: "vs last month" %>
<%# Partial: Simple divider/utility %>
<%= render "shared/ruler", classes: "my-4" %>
<%# Partial: Simple form utility %>
<%= render "shared/form_errors", model: @account %>
```
- **Keep domain logic out of the views**
```erb
<%# BAD!!! %>
<%# This belongs in the component file, not the template file! %>
<% button_classes = { class: "bg-blue-500 hover:bg-blue-600" } %>
<%= tag.button class: button_classes do %>
Save Account
<% end %>
<%# GOOD! %>
<%= tag.button class: computed_button_classes do %>
Save Account
<% end %>
```
- **Stimulus Integration in Views**
- Always use the **declarative approach** when integrating Stimulus controllers
- The ERB template should declare what happens, the Stimulus controller should respond
- Refer to [stimulus_conventions.mdc](mdc:.cursor/rules/stimulus_conventions.mdc) to learn how to incorporate them into
GOOD Stimulus controller integration into views:
```erb
<!-- Declarative - HTML declares what happens -->
<div data-controller="toggle">
<button data-action="click->toggle#toggle" data-toggle-target="button">Show</button>
<div data-toggle-target="content" class="hidden">Hello World!</div>
</div>
```
- **Stimulus Controller Placement Guidelines**
- **Component controllers** (in `app/components/`) should only be used within their component templates
- **Global controllers** (in `app/javascript/controllers/`) can be used across any view
- Pass data from Rails to Stimulus using `data-*-value` attributes, not inline JavaScript
- Use Stimulus targets to reference DOM elements, not manual `getElementById` calls
- **Naming Conventions**
- **Components**: Use `ComponentName` suffix (e.g., `ButtonComponent`, `DialogComponent`, `FilledIconComponent`)
- **Partials**: Use underscore prefix (e.g., `_trend_change.html.erb`, `_form_errors.html.erb`, `_sync_indicator.html.erb`)
- **Shared partials**: Place in `app/views/shared/` directory for reusable content
- **Context-specific partials**: Place in relevant controller view directory (e.g., `accounts/_account_sidebar_tabs.html.erb`)

View file

@ -51,6 +51,14 @@ APP_DOMAIN=
# Disable enforcing SSL connections
# DISABLE_SSL=true
# Active Record Encryption Keys (Optional)
# These keys are used to encrypt sensitive data like API keys in the database.
# If not provided, they will be automatically generated based on your SECRET_KEY_BASE.
# You can generate your own keys by running: rails db:encryption:init
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
# ======================================================================================================
# Active Storage Configuration - responsible for storing file uploads
# ======================================================================================================

5
.gitignore vendored
View file

@ -94,12 +94,11 @@ node_modules/
*.roo*
# OS specific
# Task files
.taskmaster/docs
.taskmaster/config.json
.taskmaster/templates
.taskmaster/
tasks.json
.taskmaster/tasks/
.taskmaster/reports/
.taskmaster/state.json
*.mcp.json
scripts/
.cursor/mcp.json

273
CLAUDE.md Normal file
View file

@ -0,0 +1,273 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Development Commands
### Development Server
- `bin/dev` - Start development server (Rails, Sidekiq, Tailwind CSS watcher)
- `bin/rails server` - Start Rails server only
- `bin/rails console` - Open Rails console
### Testing
- `bin/rails test` - Run all tests
- `bin/rails test:db` - Run tests with database reset
- `bin/rails test:system` - Run system tests only (use sparingly - they take longer)
- `bin/rails test test/models/account_test.rb` - Run specific test file
- `bin/rails test test/models/account_test.rb:42` - Run specific test at line
### Linting & Formatting
- `bin/rubocop` - Run Ruby linter
- `npm run lint` - Check JavaScript/TypeScript code
- `npm run lint:fix` - Fix JavaScript/TypeScript issues
- `npm run format` - Format JavaScript/TypeScript code
- `bin/brakeman` - Run security analysis
### Database
- `bin/rails db:prepare` - Create and migrate database
- `bin/rails db:migrate` - Run pending migrations
- `bin/rails db:rollback` - Rollback last migration
- `bin/rails db:seed` - Load seed data
### Setup
- `bin/setup` - Initial project setup (installs dependencies, prepares database)
## Pre-Pull Request CI Workflow
ALWAYS run these commands before opening a pull request:
1. **Tests** (Required):
- `bin/rails test` - Run all tests (always required)
- `bin/rails test:system` - Run system tests (only when applicable, they take longer)
2. **Linting** (Required):
- `bin/rubocop -f github -a` - Ruby linting with auto-correct
- `bundle exec erb_lint ./app/**/*.erb -a` - ERB linting with auto-correct
3. **Security** (Required):
- `bin/brakeman --no-pager` - Security analysis
Only proceed with pull request creation if ALL checks pass.
## General Development Rules
### Authentication Context
- Use `Current.user` for the current user. Do NOT use `current_user`.
- Use `Current.family` for the current family. Do NOT use `current_family`.
### Development Guidelines
- Prior to generating any code, carefully read the project conventions and guidelines
- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development
- Do not run `rails server` in your responses
- Do not run `touch tmp/restart.txt`
- Do not run `rails credentials`
- Do not automatically run migrations
## High-Level Architecture
### Application Modes
The Maybe app runs in two distinct modes:
- **Managed**: The Maybe team operates and manages servers for users (Rails.application.config.app_mode = "managed")
- **Self Hosted**: Users host the Maybe app on their own infrastructure, typically through Docker Compose (Rails.application.config.app_mode = "self_hosted")
### Core Domain Model
The application is built around financial data management with these key relationships:
- **User** → has many **Accounts** → has many **Transactions**
- **Account** types: checking, savings, credit cards, investments, crypto, loans, properties
- **Transaction** → belongs to **Category**, can have **Tags** and **Rules**
- **Investment accounts** → have **Holdings** → track **Securities** via **Trades**
### API Architecture
The application provides both internal and external APIs:
- Internal API: Controllers serve JSON via Turbo for SPA-like interactions
- External API: `/api/v1/` namespace with Doorkeeper OAuth and API key authentication
- API responses use Jbuilder templates for JSON rendering
- Rate limiting via Rack Attack with configurable limits per API key
### Sync & Import System
Two primary data ingestion methods:
1. **Plaid Integration**: Real-time bank account syncing
- `PlaidItem` manages connections
- `Sync` tracks sync operations
- Background jobs handle data updates
2. **CSV Import**: Manual data import with mapping
- `Import` manages import sessions
- Supports transaction and balance imports
- Custom field mapping with transformation rules
### Background Processing
Sidekiq handles asynchronous tasks:
- Account syncing (`SyncAccountsJob`)
- Import processing (`ImportDataJob`)
- AI chat responses (`CreateChatResponseJob`)
- Scheduled maintenance via sidekiq-cron
### Frontend Architecture
- **Hotwire Stack**: Turbo + Stimulus for reactive UI without heavy JavaScript
- **ViewComponents**: Reusable UI components in `app/components/`
- **Stimulus Controllers**: Handle interactivity, organized alongside components
- **Charts**: D3.js for financial visualizations (time series, donut, sankey)
- **Styling**: Tailwind CSS v4.x with custom design system
- Design system defined in `app/assets/tailwind/maybe-design-system.css`
- Always use functional tokens (e.g., `text-primary` not `text-white`)
- Prefer semantic HTML elements over JS components
- Use `icon` helper for icons, never `lucide_icon` directly
### Multi-Currency Support
- All monetary values stored in base currency (user's primary currency)
- Exchange rates fetched from Synth API
- `Money` objects handle currency conversion and formatting
- Historical exchange rates for accurate reporting
### Security & Authentication
- Session-based auth for web users
- API authentication via:
- OAuth2 (Doorkeeper) for third-party apps
- API keys with JWT tokens for direct API access
- Scoped permissions system for API access
- Strong parameters and CSRF protection throughout
### Testing Philosophy
- Comprehensive test coverage using Rails' built-in Minitest
- Fixtures for test data (avoid FactoryBot)
- Keep fixtures minimal (2-3 per model for base cases)
- VCR for external API testing
- System tests for critical user flows (use sparingly)
- Test helpers in `test/support/` for common scenarios
- Only test critical code paths that significantly increase confidence
- Write tests as you go, when required
### Performance Considerations
- Database queries optimized with proper indexes
- N+1 queries prevented via includes/joins
- Background jobs for heavy operations
- Caching strategies for expensive calculations
- Turbo Frames for partial page updates
### Development Workflow
- Feature branches merged to `main`
- Docker support for consistent environments
- Environment variables via `.env` files
- Lookbook for component development (`/lookbook`)
- Letter Opener for email preview in development
## Project Conventions
### Convention 1: Minimize Dependencies
- Push Rails to its limits before adding new dependencies
- Strong technical/business reason required for new dependencies
- Favor old and reliable over new and flashy
### Convention 2: Skinny Controllers, Fat Models
- Business logic in `app/models/` folder, avoid `app/services/`
- Use Rails concerns and POROs for organization
- Models should answer questions about themselves: `account.balance_series` not `AccountSeries.new(account).call`
### Convention 3: Hotwire-First Frontend
- **Native HTML preferred over JS components**
- Use `<dialog>` for modals, `<details><summary>` for disclosures
- **Leverage Turbo frames** for page sections over client-side solutions
- **Query params for state** over localStorage/sessions
- **Server-side formatting** for currencies, numbers, dates
- **Always use `icon` helper** in `application_helper.rb`, NEVER `lucide_icon` directly
### Convention 4: Optimize for Simplicity
- Prioritize good OOP domain design over performance
- Focus performance only on critical/global areas (avoid N+1 queries, mindful of global layouts)
### Convention 5: Database vs ActiveRecord Validations
- Simple validations (null checks, unique indexes) in DB
- ActiveRecord validations for convenience in forms (prefer client-side when possible)
- Complex validations and business logic in ActiveRecord
## TailwindCSS Design System
### Design System Rules
- **Always reference `app/assets/tailwind/maybe-design-system.css`** for primitives and tokens
- **Use functional tokens** defined in design system:
- `text-primary` instead of `text-white`
- `bg-container` instead of `bg-white`
- `border border-primary` instead of `border border-gray-200`
- **NEVER create new styles** in design system files without permission
- **Always generate semantic HTML**
## Component Architecture
### ViewComponent vs Partials Decision Making
**Use ViewComponents when:**
- Element has complex logic or styling patterns
- Element will be reused across multiple views/contexts
- Element needs structured styling with variants/sizes
- Element requires interactive behavior or Stimulus controllers
- Element has configurable slots or complex APIs
- Element needs accessibility features or ARIA support
**Use Partials when:**
- Element is primarily static HTML with minimal logic
- Element is used in only one or few specific contexts
- Element is simple template content
- Element doesn't need variants, sizes, or complex configuration
- Element is more about content organization than reusable functionality
**Component Guidelines:**
- Prefer components over partials when available
- Keep domain logic OUT of view templates
- Logic belongs in component files, not template files
### Stimulus Controller Guidelines
**Declarative Actions (Required):**
```erb
<!-- GOOD: Declarative - HTML declares what happens -->
<div data-controller="toggle">
<button data-action="click->toggle#toggle" data-toggle-target="button">Show</button>
<div data-toggle-target="content" class="hidden">Hello World!</div>
</div>
```
**Controller Best Practices:**
- Keep controllers lightweight and simple (< 7 targets)
- Use private methods and expose clear public API
- Single responsibility or highly related responsibilities
- Component controllers stay in component directory, global controllers in `app/javascript/controllers/`
- Pass data via `data-*-value` attributes, not inline JavaScript
## Testing Philosophy
### General Testing Rules
- **ALWAYS use Minitest + fixtures** (NEVER RSpec or factories)
- Keep fixtures minimal (2-3 per model for base cases)
- Create edge cases on-the-fly within test context
- Use Rails helpers for large fixture creation needs
### Test Quality Guidelines
- **Write minimal, effective tests** - system tests sparingly
- **Only test critical and important code paths**
- **Test boundaries correctly:**
- Commands: test they were called with correct params
- Queries: test output
- Don't test implementation details of other classes
### Testing Examples
```ruby
# GOOD - Testing critical domain business logic
test "syncs balances" do
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
assert_difference "@account.balances.count", 2 do
Balance::Syncer.new(@account, strategy: :forward).sync_balances
end
end
# BAD - Testing ActiveRecord functionality
test "saves balance" do
balance_record = Balance.new(balance: 100, currency: "USD")
assert balance_record.save
end
```
### Stubs and Mocks
- Use `mocha` gem
- Prefer `OpenStruct` for mock instances
- Only mock what's necessary

View file

@ -26,7 +26,7 @@ gem "view_component"
# https://github.com/lookbook-hq/lookbook/issues/712
# TODO: Remove max version constraint when fixed
gem "lookbook", "2.3.9"
gem "lookbook", "2.3.11"
gem "hotwire_combobox"
@ -51,6 +51,11 @@ gem "image_processing", ">= 1.2"
gem "ostruct"
gem "bcrypt", "~> 3.1"
gem "jwt"
gem "jbuilder"
# OAuth & API Security
gem "doorkeeper"
gem "rack-attack", "~> 6.6"
gem "faraday"
gem "faraday-retry"
gem "faraday-multipart"

View file

@ -151,7 +151,7 @@ GEM
addressable
csv (3.3.5)
date (3.4.1)
debug (1.10.0)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
derailed_benchmarks (2.2.1)
@ -173,6 +173,8 @@ GEM
ruby2_keywords
thor (>= 0.19, < 2)
docile (1.4.1)
doorkeeper (5.8.2)
railties (>= 5)
dotenv (3.1.8)
dotenv-rails (3.1.8)
dotenv (= 3.1.8)
@ -196,11 +198,11 @@ GEM
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-multipart (1.1.0)
faraday-multipart (1.1.1)
multipart-post (~> 2.0)
faraday-net_http (3.4.1)
net-http (>= 0.5.0)
faraday-retry (2.3.1)
faraday-retry (2.3.2)
faraday (~> 2.0)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
@ -266,6 +268,9 @@ GEM
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.13.0)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
jmespath (1.6.2)
json (2.12.2)
jwt (2.10.1)
@ -296,7 +301,7 @@ GEM
loofah (2.24.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
lookbook (2.3.9)
lookbook (2.3.11)
activemodel
css_parser
htmlbeautifier (~> 1.3)
@ -359,7 +364,7 @@ GEM
octokit (10.0.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
ostruct (0.6.1)
ostruct (0.6.2)
pagy (9.3.4)
parallel (1.27.0)
parser (3.3.8.0)
@ -390,6 +395,8 @@ GEM
raabro (1.4.0)
racc (1.8.1)
rack (3.1.16)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-mini-profiler (4.0.0)
rack (>= 1.2.0)
rack-session (2.1.1)
@ -441,7 +448,7 @@ GEM
ffi (~> 1.0)
rbs (3.9.4)
logger
rdoc (6.14.0)
rdoc (6.14.1)
erb
psych (>= 4.0.0)
redcarpet (3.6.1)
@ -625,6 +632,7 @@ DEPENDENCIES
csv
debug
derailed_benchmarks
doorkeeper
dotenv-rails
erb_lint
faker
@ -639,10 +647,11 @@ DEPENDENCIES
importmap-rails
inline_svg
intercom-rails
jbuilder
jwt
letter_opener
logtail-rails
lookbook (= 2.3.9)
lookbook (= 2.3.11)
lucide-rails!
mocha
octokit
@ -652,6 +661,7 @@ DEPENDENCIES
plaid
propshaft
puma (>= 5.0)
rack-attack (~> 6.6)
rack-mini-profiler
rails (~> 7.2.2)
rails-settings-cached

View file

@ -0,0 +1,59 @@
# frozen_string_literal: true
class Api::V1::AccountsController < Api::V1::BaseController
include Pagy::Backend
# Ensure proper scope authorization for read access
before_action :ensure_read_scope
def index
# Test with Pagy pagination
family = current_resource_owner.family
accounts_query = family.accounts.active.alphabetically
# Handle pagination with Pagy
@pagy, @accounts = pagy(
accounts_query,
page: safe_page_param,
limit: safe_per_page_param
)
@per_page = safe_per_page_param
# Rails will automatically use app/views/api/v1/accounts/index.json.jbuilder
render :index
rescue => e
Rails.logger.error "AccountsController error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
}, status: :internal_server_error
end
private
def ensure_read_scope
authorize_scope!(:read)
end
def safe_page_param
page = params[:page].to_i
page > 0 ? page : 1
end
def safe_per_page_param
per_page = params[:per_page].to_i
# Default to 25, max 100
case per_page
when 1..100
per_page
else
25
end
end
end

View file

@ -0,0 +1,210 @@
module Api
module V1
class AuthController < BaseController
include Invitable
skip_before_action :authenticate_request!
skip_before_action :check_api_key_rate_limit
skip_before_action :log_api_access
def signup
# Check if invite code is required
if invite_code_required? && params[:invite_code].blank?
render json: { error: "Invite code is required" }, status: :forbidden
return
end
# Validate invite code if provided
if params[:invite_code].present? && !InviteCode.exists?(token: params[:invite_code]&.downcase)
render json: { error: "Invalid invite code" }, status: :forbidden
return
end
# Validate password
password_errors = validate_password(params[:user][:password])
if password_errors.any?
render json: { errors: password_errors }, status: :unprocessable_entity
return
end
# Validate device info
unless valid_device_info?
render json: { error: "Device information is required" }, status: :bad_request
return
end
user = User.new(user_signup_params)
# Create family for new user
family = Family.new
user.family = family
user.role = :admin
if user.save
# Claim invite code if provided
InviteCode.claim!(params[:invite_code]) if params[:invite_code].present?
# Create device and OAuth token
device = create_or_update_device(user)
token_response = create_oauth_token_for_device(user, device)
render json: token_response.merge(
user: {
id: user.id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name
}
), status: :created
else
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
end
end
def login
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
# Check MFA if enabled
if user.otp_required?
unless params[:otp_code].present? && user.verify_otp?(params[:otp_code])
render json: {
error: "Two-factor authentication required",
mfa_required: true
}, status: :unauthorized
return
end
end
# Validate device info
unless valid_device_info?
render json: { error: "Device information is required" }, status: :bad_request
return
end
# Create device and OAuth token
device = create_or_update_device(user)
token_response = create_oauth_token_for_device(user, device)
render json: token_response.merge(
user: {
id: user.id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name
}
)
else
render json: { error: "Invalid email or password" }, status: :unauthorized
end
end
def refresh
# Find the refresh token
refresh_token = params[:refresh_token]
unless refresh_token.present?
render json: { error: "Refresh token is required" }, status: :bad_request
return
end
# Find the access token associated with this refresh token
access_token = Doorkeeper::AccessToken.by_refresh_token(refresh_token)
if access_token.nil? || access_token.revoked?
render json: { error: "Invalid refresh token" }, status: :unauthorized
return
end
# Create new access token
new_token = Doorkeeper::AccessToken.create!(
application: access_token.application,
resource_owner_id: access_token.resource_owner_id,
expires_in: 30.days.to_i,
scopes: access_token.scopes,
use_refresh_token: true
)
# Revoke old access token
access_token.revoke
# Update device last seen
user = User.find(access_token.resource_owner_id)
device = user.mobile_devices.find_by(device_id: params[:device][:device_id])
device&.update_last_seen!
render json: {
access_token: new_token.plaintext_token,
refresh_token: new_token.plaintext_refresh_token,
token_type: "Bearer",
expires_in: new_token.expires_in,
created_at: new_token.created_at.to_i
}
end
private
def user_signup_params
params.require(:user).permit(:email, :password, :first_name, :last_name)
end
def validate_password(password)
errors = []
if password.blank?
errors << "Password can't be blank"
return errors
end
errors << "Password must be at least 8 characters" if password.length < 8
errors << "Password must include both uppercase and lowercase letters" unless password.match?(/[A-Z]/) && password.match?(/[a-z]/)
errors << "Password must include at least one number" unless password.match?(/\d/)
errors << "Password must include at least one special character" unless password.match?(/[!@#$%^&*(),.?":{}|<>]/)
errors
end
def valid_device_info?
device = params[:device]
return false if device.nil?
required_fields = %w[device_id device_name device_type os_version app_version]
required_fields.all? { |field| device[field].present? }
end
def create_or_update_device(user)
# Handle both string and symbol keys
device_data = params[:device].permit(:device_id, :device_name, :device_type, :os_version, :app_version)
device = user.mobile_devices.find_or_initialize_by(device_id: device_data[:device_id])
device.update!(device_data.merge(last_seen_at: Time.current))
device
end
def create_oauth_token_for_device(user, device)
# Create OAuth application for this device if needed
oauth_app = device.create_oauth_application!
# Revoke any existing tokens for this device
device.revoke_all_tokens!
# Create new access token with 30-day expiration
access_token = Doorkeeper::AccessToken.create!(
application: oauth_app,
resource_owner_id: user.id,
expires_in: 30.days.to_i,
scopes: "read_write",
use_refresh_token: true
)
{
access_token: access_token.plaintext_token,
refresh_token: access_token.plaintext_refresh_token,
token_type: "Bearer",
expires_in: access_token.expires_in,
created_at: access_token.created_at.to_i
}
end
end
end
end

View file

@ -0,0 +1,279 @@
# frozen_string_literal: true
class Api::V1::BaseController < ApplicationController
include Doorkeeper::Rails::Helpers
# Skip regular session-based authentication for API
skip_authentication
# Skip CSRF protection for API endpoints
skip_before_action :verify_authenticity_token
# Skip onboarding requirements for API endpoints
skip_before_action :require_onboarding_and_upgrade
# Force JSON format for all API requests
before_action :force_json_format
# Use our custom authentication that supports both OAuth and API keys
before_action :authenticate_request!
before_action :check_api_key_rate_limit
before_action :log_api_access
# Override Doorkeeper's default behavior to return JSON instead of redirecting
def doorkeeper_unauthorized_render_options(error: nil)
{ json: { error: "unauthorized", message: "Access token is invalid, expired, or missing" } }
end
# Error handling for common API errors
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
rescue_from Doorkeeper::Errors::DoorkeeperError, with: :handle_unauthorized
rescue_from ActionController::ParameterMissing, with: :handle_bad_request
private
# Force JSON format for all API requests
def force_json_format
request.format = :json
end
# Authenticate using either OAuth or API key
def authenticate_request!
return if authenticate_oauth
return if authenticate_api_key
render_unauthorized unless performed?
end
# Try OAuth authentication first
def authenticate_oauth
return false unless request.headers["Authorization"].present?
# Manually verify the token (bypassing doorkeeper_authorize! which had scope issues)
token_string = request.authorization&.split(" ")&.last
access_token = Doorkeeper::AccessToken.by_token(token_string)
# Check token validity and scope (read_write includes read access)
has_sufficient_scope = access_token&.scopes&.include?("read") || access_token&.scopes&.include?("read_write")
unless access_token && !access_token.expired? && has_sufficient_scope
render_json({ error: "unauthorized", message: "Access token is invalid, expired, or missing required scope" }, status: :unauthorized)
return false
end
# Set the doorkeeper_token for compatibility
@_doorkeeper_token = access_token
if doorkeeper_token&.resource_owner_id
@current_user = User.find_by(id: doorkeeper_token.resource_owner_id)
# If user doesn't exist, the token is invalid (user was deleted)
unless @current_user
Rails.logger.warn "API OAuth Token Invalid: Access token resource_owner_id #{doorkeeper_token.resource_owner_id} does not exist"
render_json({ error: "unauthorized", message: "Access token is invalid - user not found" }, status: :unauthorized)
return false
end
else
Rails.logger.warn "API OAuth Token Invalid: Access token missing resource_owner_id"
render_json({ error: "unauthorized", message: "Access token is invalid - missing resource owner" }, status: :unauthorized)
return false
end
@authentication_method = :oauth
setup_current_context_for_api
true
rescue Doorkeeper::Errors::DoorkeeperError => e
Rails.logger.warn "API OAuth Error: #{e.message}"
false
end
# Try API key authentication
def authenticate_api_key
api_key_value = request.headers["X-Api-Key"]
return false unless api_key_value
@api_key = ApiKey.find_by_value(api_key_value)
return false unless @api_key && @api_key.active?
@current_user = @api_key.user
@api_key.update_last_used!
@authentication_method = :api_key
@rate_limiter = ApiRateLimiter.new(@api_key)
setup_current_context_for_api
true
end
# Check rate limits for API key authentication
def check_api_key_rate_limit
return unless @authentication_method == :api_key && @rate_limiter
if @rate_limiter.rate_limit_exceeded?
usage_info = @rate_limiter.usage_info
render_rate_limit_exceeded(usage_info)
return false
end
# Increment request count for successful API key requests
@rate_limiter.increment_request_count!
# Add rate limit headers to response
add_rate_limit_headers(@rate_limiter.usage_info)
end
# Render rate limit exceeded response
def render_rate_limit_exceeded(usage_info)
response.headers["X-RateLimit-Limit"] = usage_info[:rate_limit].to_s
response.headers["X-RateLimit-Remaining"] = "0"
response.headers["X-RateLimit-Reset"] = usage_info[:reset_time].to_s
response.headers["Retry-After"] = usage_info[:reset_time].to_s
Rails.logger.warn "API Rate Limit Exceeded: API Key #{@api_key.name} (User: #{@current_user.email}) - #{usage_info[:current_count]}/#{usage_info[:rate_limit]} requests"
render_json({
error: "rate_limit_exceeded",
message: "Rate limit exceeded. Try again in #{usage_info[:reset_time]} seconds.",
details: {
limit: usage_info[:rate_limit],
current: usage_info[:current_count],
reset_in_seconds: usage_info[:reset_time]
}
}, status: :too_many_requests)
end
# Add rate limit headers to successful responses
def add_rate_limit_headers(usage_info)
response.headers["X-RateLimit-Limit"] = usage_info[:rate_limit].to_s
response.headers["X-RateLimit-Remaining"] = usage_info[:remaining].to_s
response.headers["X-RateLimit-Reset"] = usage_info[:reset_time].to_s
end
# Render unauthorized response
def render_unauthorized
render_json({ error: "unauthorized", message: "Access token or API key is invalid, expired, or missing" }, status: :unauthorized)
end
# Returns the user that owns the access token or API key
def current_resource_owner
@current_user
end
# Get current scopes from either authentication method
def current_scopes
case @authentication_method
when :oauth
doorkeeper_token&.scopes&.to_a || []
when :api_key
@api_key&.scopes || []
else
[]
end
end
# Check if the current authentication has the required scope
# Implements hierarchical scope checking where read_write includes read access
def authorize_scope!(required_scope)
scopes = current_scopes
case required_scope.to_s
when "read"
# Read access requires either "read" or "read_write" scope
has_access = scopes.include?("read") || scopes.include?("read_write")
when "write"
# Write access requires "read_write" scope
has_access = scopes.include?("read_write")
else
# For any other scope, check exact match (backward compatibility)
has_access = scopes.include?(required_scope.to_s)
end
unless has_access
Rails.logger.warn "API Insufficient Scope: User #{current_resource_owner&.email} attempted to access #{required_scope} but only has #{scopes}"
render_json({ error: "insufficient_scope", message: "This action requires the '#{required_scope}' scope" }, status: :forbidden)
return false
end
true
end
# Consistent JSON response method
def render_json(data, status: :ok)
render json: data, status: status
end
# Error handlers
def handle_not_found(exception)
Rails.logger.warn "API Record Not Found: #{exception.message}"
render_json({ error: "record_not_found", message: "The requested resource was not found" }, status: :not_found)
end
def handle_unauthorized(exception)
Rails.logger.warn "API Unauthorized: #{exception.message}"
render_json({ error: "unauthorized", message: "Access token is invalid or expired" }, status: :unauthorized)
end
def handle_bad_request(exception)
Rails.logger.warn "API Bad Request: #{exception.message}"
render_json({ error: "bad_request", message: "Required parameters are missing or invalid" }, status: :bad_request)
end
# Log API access for monitoring and debugging
def log_api_access
return unless current_resource_owner
auth_info = case @authentication_method
when :oauth
"OAuth Token"
when :api_key
"API Key: #{@api_key.name}"
else
"Unknown"
end
Rails.logger.info "API Request: #{request.method} #{request.path} - User: #{current_resource_owner.email} (Family: #{current_resource_owner.family_id}) - Auth: #{auth_info}"
end
# Family-based access control helper (to be used by subcontrollers)
def ensure_current_family_access(resource)
return unless resource.respond_to?(:family_id)
unless resource.family_id == current_resource_owner.family_id
Rails.logger.warn "API Forbidden: User #{current_resource_owner.email} attempted to access resource from family #{resource.family_id}"
render_json({ error: "forbidden", message: "Access denied to this resource" }, status: :forbidden)
return false
end
true
end
# Manual doorkeeper_token accessor for compatibility with manual token verification
def doorkeeper_token
@_doorkeeper_token
end
# Set up Current context for API requests since we don't use session-based auth
def setup_current_context_for_api
# For API requests, we need to create a minimal session-like object
# or find/create an actual session for this user to make Current.user work
if @current_user
# Try to find an existing session for this user, or create a temporary one
session = @current_user.sessions.first
if session
Current.session = session
else
# Create a temporary session for this API request
# This won't be persisted but will allow Current.user to work
session = @current_user.sessions.build(
user_agent: request.user_agent,
ip_address: request.ip
)
Current.session = session
end
end
end
# Check if AI features are enabled for the current user
def require_ai_enabled
unless current_resource_owner&.ai_enabled?
render_json({ error: "feature_disabled", message: "AI features are not enabled for this user" }, status: :forbidden)
end
end
end

View file

@ -0,0 +1,84 @@
# frozen_string_literal: true
class Api::V1::ChatsController < Api::V1::BaseController
include Pagy::Backend
before_action :require_ai_enabled
before_action :ensure_read_scope, only: [ :index, :show ]
before_action :ensure_write_scope, only: [ :create, :update, :destroy ]
before_action :set_chat, only: [ :show, :update, :destroy ]
def index
@pagy, @chats = pagy(Current.user.chats.ordered, items: 20)
end
def show
return unless @chat
@pagy, @messages = pagy(@chat.messages.ordered, items: 50)
end
def create
@chat = Current.user.chats.build(title: chat_params[:title])
if @chat.save
if chat_params[:message].present?
@message = @chat.messages.build(
content: chat_params[:message],
type: "UserMessage",
ai_model: chat_params[:model] || "gpt-4"
)
if @message.save
AssistantResponseJob.perform_later(@message)
render :show, status: :created
else
@chat.destroy
render json: { error: "Failed to create initial message", details: @message.errors.full_messages }, status: :unprocessable_entity
end
else
render :show, status: :created
end
else
render json: { error: "Failed to create chat", details: @chat.errors.full_messages }, status: :unprocessable_entity
end
end
def update
return unless @chat
if @chat.update(update_chat_params)
render :show
else
render json: { error: "Failed to update chat", details: @chat.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
return unless @chat
@chat.destroy
head :no_content
end
private
def ensure_read_scope
authorize_scope!(:read)
end
def ensure_write_scope
authorize_scope!(:write)
end
def set_chat
@chat = Current.user.chats.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Chat not found" }, status: :not_found
end
def chat_params
params.permit(:title, :message, :model)
end
def update_chat_params
params.permit(:title)
end
end

View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
class Api::V1::MessagesController < Api::V1::BaseController
before_action :require_ai_enabled
before_action :ensure_write_scope, only: [ :create, :retry ]
before_action :set_chat
def create
@message = @chat.messages.build(
content: message_params[:content],
type: "UserMessage",
ai_model: message_params[:model] || "gpt-4"
)
if @message.save
AssistantResponseJob.perform_later(@message)
render :show, status: :created
else
render json: { error: "Failed to create message", details: @message.errors.full_messages }, status: :unprocessable_entity
end
end
def retry
last_message = @chat.messages.ordered.last
if last_message&.type == "AssistantMessage"
new_message = @chat.messages.create!(
type: "AssistantMessage",
content: "",
ai_model: last_message.ai_model
)
AssistantResponseJob.perform_later(new_message)
render json: { message: "Retry initiated", message_id: new_message.id }, status: :accepted
else
render json: { error: "No assistant message to retry" }, status: :unprocessable_entity
end
end
private
def ensure_write_scope
authorize_scope!(:write)
end
def set_chat
@chat = Current.user.chats.find(params[:chat_id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Chat not found" }, status: :not_found
end
def message_params
params.permit(:content, :model)
end
end

View file

@ -0,0 +1,47 @@
# frozen_string_literal: true
# Test controller for API V1 Base Controller functionality
# This controller is only used for testing the base controller behavior
class Api::V1::TestController < Api::V1::BaseController
def index
render_json({ message: "test_success", user: current_resource_owner&.email })
end
def not_found
# Trigger RecordNotFound error for testing error handling
raise ActiveRecord::RecordNotFound, "Test record not found"
end
def family_access
# Test family-based access control
# Create a mock resource that belongs to a different family
mock_resource = OpenStruct.new(family_id: 999) # Different family ID
# Check family access - if it returns false, it already rendered the error
if ensure_current_family_access(mock_resource)
# If we get here, access was allowed
render_json({ family_id: current_resource_owner.family_id })
end
end
def scope_required
# Test scope authorization - require write scope
return unless authorize_scope!("write")
render_json({
message: "scope_authorized",
scopes: current_scopes,
required_scope: "write"
})
end
def multiple_scopes_required
# Test read scope requirement
return unless authorize_scope!("read")
render_json({
message: "read_scope_authorized",
scopes: current_scopes
})
end
end

View file

@ -0,0 +1,327 @@
# frozen_string_literal: true
class Api::V1::TransactionsController < Api::V1::BaseController
include Pagy::Backend
# Ensure proper scope authorization for read vs write access
before_action :ensure_read_scope, only: [ :index, :show ]
before_action :ensure_write_scope, only: [ :create, :update, :destroy ]
before_action :set_transaction, only: [ :show, :update, :destroy ]
def index
family = current_resource_owner.family
transactions_query = family.transactions.active
# Apply filters
transactions_query = apply_filters(transactions_query)
# Apply search
transactions_query = apply_search(transactions_query) if params[:search].present?
# Include necessary associations for efficient queries
transactions_query = transactions_query.includes(
{ entry: :account },
:category, :merchant, :tags,
transfer_as_outflow: { inflow_transaction: { entry: :account } },
transfer_as_inflow: { outflow_transaction: { entry: :account } }
).reverse_chronological
# Handle pagination with Pagy
@pagy, @transactions = pagy(
transactions_query,
page: safe_page_param,
limit: safe_per_page_param
)
# Make per_page available to the template
@per_page = safe_per_page_param
# Rails will automatically use app/views/api/v1/transactions/index.json.jbuilder
render :index
rescue => e
Rails.logger.error "TransactionsController#index error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
}, status: :internal_server_error
end
def show
# Rails will automatically use app/views/api/v1/transactions/show.json.jbuilder
render :show
rescue => e
Rails.logger.error "TransactionsController#show error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
}, status: :internal_server_error
end
def create
family = current_resource_owner.family
# Validate account_id is present
unless transaction_params[:account_id].present?
render json: {
error: "validation_failed",
message: "Account ID is required",
errors: [ "Account ID is required" ]
}, status: :unprocessable_entity
return
end
account = family.accounts.find(transaction_params[:account_id])
@entry = account.entries.new(entry_params_for_create)
if @entry.save
@entry.sync_account_later
@entry.lock_saved_attributes!
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
@transaction = @entry.transaction
render :show, status: :created
else
render json: {
error: "validation_failed",
message: "Transaction could not be created",
errors: @entry.errors.full_messages
}, status: :unprocessable_entity
end
rescue => e
Rails.logger.error "TransactionsController#create error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
}, status: :internal_server_error
end
def update
if @entry.update(entry_params_for_update)
@entry.sync_account_later
@entry.lock_saved_attributes!
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
@transaction = @entry.transaction
render :show
else
render json: {
error: "validation_failed",
message: "Transaction could not be updated",
errors: @entry.errors.full_messages
}, status: :unprocessable_entity
end
rescue => e
Rails.logger.error "TransactionsController#update error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
}, status: :internal_server_error
end
def destroy
@entry.destroy!
@entry.sync_account_later
render json: {
message: "Transaction deleted successfully"
}, status: :ok
rescue => e
Rails.logger.error "TransactionsController#destroy error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
}, status: :internal_server_error
end
private
def set_transaction
family = current_resource_owner.family
@transaction = family.transactions.find(params[:id])
@entry = @transaction.entry
rescue ActiveRecord::RecordNotFound
render json: {
error: "not_found",
message: "Transaction not found"
}, status: :not_found
end
def ensure_read_scope
authorize_scope!(:read)
end
def ensure_write_scope
authorize_scope!(:write)
end
def apply_filters(query)
# Account filtering
if params[:account_id].present?
query = query.joins(:entry).where(entries: { account_id: params[:account_id] })
end
if params[:account_ids].present?
account_ids = Array(params[:account_ids])
query = query.joins(:entry).where(entries: { account_id: account_ids })
end
# Category filtering
if params[:category_id].present?
query = query.where(category_id: params[:category_id])
end
if params[:category_ids].present?
category_ids = Array(params[:category_ids])
query = query.where(category_id: category_ids)
end
# Merchant filtering
if params[:merchant_id].present?
query = query.where(merchant_id: params[:merchant_id])
end
if params[:merchant_ids].present?
merchant_ids = Array(params[:merchant_ids])
query = query.where(merchant_id: merchant_ids)
end
# Date range filtering
if params[:start_date].present?
query = query.joins(:entry).where("entries.date >= ?", Date.parse(params[:start_date]))
end
if params[:end_date].present?
query = query.joins(:entry).where("entries.date <= ?", Date.parse(params[:end_date]))
end
# Amount filtering
if params[:min_amount].present?
min_amount = params[:min_amount].to_f
query = query.joins(:entry).where("entries.amount >= ?", min_amount)
end
if params[:max_amount].present?
max_amount = params[:max_amount].to_f
query = query.joins(:entry).where("entries.amount <= ?", max_amount)
end
# Tag filtering
if params[:tag_ids].present?
tag_ids = Array(params[:tag_ids])
query = query.joins(:tags).where(tags: { id: tag_ids })
end
# Transaction type filtering (income/expense)
if params[:type].present?
case params[:type].downcase
when "income"
query = query.joins(:entry).where("entries.amount < 0")
when "expense"
query = query.joins(:entry).where("entries.amount > 0")
end
end
query
end
def apply_search(query)
search_term = "%#{params[:search]}%"
query.joins(:entry)
.left_joins(:merchant)
.where(
"entries.name ILIKE ? OR entries.notes ILIKE ? OR merchants.name ILIKE ?",
search_term, search_term, search_term
)
end
def transaction_params
params.require(:transaction).permit(
:account_id, :date, :amount, :name, :description, :notes, :currency,
:category_id, :merchant_id, :nature, tag_ids: []
)
end
def entry_params_for_create
entry_params = {
name: transaction_params[:name] || transaction_params[:description],
date: transaction_params[:date],
amount: calculate_signed_amount,
currency: transaction_params[:currency] || current_resource_owner.family.currency,
notes: transaction_params[:notes],
entryable_type: "Transaction",
entryable_attributes: {
category_id: transaction_params[:category_id],
merchant_id: transaction_params[:merchant_id],
tag_ids: transaction_params[:tag_ids] || []
}
}
entry_params.compact
end
def entry_params_for_update
entry_params = {
name: transaction_params[:name] || transaction_params[:description],
date: transaction_params[:date],
notes: transaction_params[:notes],
entryable_attributes: {
id: @entry.entryable_id,
category_id: transaction_params[:category_id],
merchant_id: transaction_params[:merchant_id],
tag_ids: transaction_params[:tag_ids]
}.compact_blank
}
# Only update amount if provided
if transaction_params[:amount].present?
entry_params[:amount] = calculate_signed_amount
end
entry_params.compact
end
def calculate_signed_amount
amount = transaction_params[:amount].to_f
nature = transaction_params[:nature]
case nature&.downcase
when "income", "inflow"
-amount.abs # Income is negative
when "expense", "outflow"
amount.abs # Expense is positive
else
amount # Use as provided
end
end
def safe_page_param
page = params[:page].to_i
page > 0 ? page : 1
end
def safe_per_page_param
per_page = params[:per_page].to_i
case per_page
when 1..100
per_page
else
25 # Default
end
end
end

View file

@ -0,0 +1,38 @@
class Api::V1::UsageController < Api::V1::BaseController
# GET /api/v1/usage
def show
return unless authorize_scope!(:read)
case @authentication_method
when :api_key
usage_info = @rate_limiter.usage_info
render_json({
api_key: {
name: @api_key.name,
scopes: @api_key.scopes,
last_used_at: @api_key.last_used_at,
created_at: @api_key.created_at
},
rate_limit: {
tier: usage_info[:tier],
limit: usage_info[:rate_limit],
current_count: usage_info[:current_count],
remaining: usage_info[:remaining],
reset_in_seconds: usage_info[:reset_time],
reset_at: Time.current + usage_info[:reset_time].seconds
}
})
when :oauth
# For OAuth, we don't track detailed usage yet, but we can return basic info
render_json({
authentication_method: "oauth",
message: "Detailed usage tracking is available for API key authentication"
})
else
render_json({
error: "invalid_authentication_method",
message: "Unable to determine usage information"
}, status: :bad_request)
end
end
end

View file

@ -25,6 +25,7 @@ module Onboardable
return false if path.starts_with?("/subscription")
return false if path.starts_with?("/onboarding")
return false if path.starts_with?("/users")
return false if path.starts_with?("/api") # Exclude API endpoints from onboarding redirects
[
new_registration_path,

View file

@ -0,0 +1,61 @@
# frozen_string_literal: true
class Settings::ApiKeysController < ApplicationController
layout "settings"
before_action :set_api_key, only: [ :show, :destroy ]
def show
@current_api_key = @api_key
end
def new
# Allow regeneration by not redirecting if user explicitly wants to create a new key
# Only redirect if user stumbles onto new page without explicit intent
redirect_to settings_api_key_path if Current.user.api_keys.active.exists? && !params[:regenerate]
@api_key = ApiKey.new
end
def create
@plain_key = ApiKey.generate_secure_key
@api_key = Current.user.api_keys.build(api_key_params)
@api_key.key = @plain_key
# Temporarily revoke existing keys for validation to pass
existing_keys = Current.user.api_keys.active
existing_keys.each { |key| key.update_column(:revoked_at, Time.current) }
if @api_key.save
flash[:notice] = "Your API key has been created successfully"
redirect_to settings_api_key_path
else
# Restore existing keys if new key creation failed
existing_keys.each { |key| key.update_column(:revoked_at, nil) }
render :new, status: :unprocessable_entity
end
end
def destroy
if @api_key&.revoke!
flash[:notice] = "API key has been revoked successfully"
else
flash[:alert] = "Failed to revoke API key"
end
redirect_to settings_api_key_path
end
private
def set_api_key
@api_key = Current.user.api_keys.active.first
end
def api_key_params
# Convert single scope value to array for storage
permitted_params = params.require(:api_key).permit(:name, :scopes)
if permitted_params[:scopes].present?
permitted_params[:scopes] = [ permitted_params[:scopes] ]
end
permitted_params
end
end

View file

@ -1,17 +1,27 @@
class TradesController < ApplicationController
include EntryableResource
# Defaults to a buy trade
def new
@account = Current.family.accounts.find_by(id: params[:account_id])
@model = Current.family.entries.new(
account: @account,
currency: @account ? @account.currency : Current.family.currency,
entryable: Trade.new
)
end
# Can create a trade, transaction (e.g. "fees"), or transfer (e.g. "withdrawal")
def create
@entry = build_entry
if @entry.save
@entry.sync_account_later
@account = Current.family.accounts.find(params[:account_id])
@model = Trade::CreateForm.new(create_params.merge(account: @account)).create
if @model.persisted?
flash[:notice] = t("entries.create.success")
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account) }
format.turbo_stream { stream_redirect_back_or_to account_path(@entry.account) }
format.html { redirect_back_or_to account_path(@account) }
format.turbo_stream { stream_redirect_back_or_to account_path(@account) }
end
else
render :new, status: :unprocessable_entity
@ -41,11 +51,6 @@ class TradesController < ApplicationController
end
private
def build_entry
account = Current.family.accounts.find(params.dig(:entry, :account_id))
TradeBuilder.new(create_entry_params.merge(account: account))
end
def entry_params
params.require(:entry).permit(
:name, :date, :amount, :currency, :excluded, :notes, :nature,
@ -53,8 +58,8 @@ class TradesController < ApplicationController
)
end
def create_entry_params
params.require(:entry).permit(
def create_params
params.require(:model).permit(
:date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id
)
end

View file

@ -3,8 +3,6 @@ class TransactionsController < ApplicationController
before_action :store_params!, only: :index
require "digest/md5"
def new
super
@income_categories = Current.family.categories.incomes.alphabetically
@ -13,95 +11,22 @@ class TransactionsController < ApplicationController
def index
@q = search_params
transactions_query = Current.family.transactions.active.search(@q)
@search = Transaction::Search.new(Current.family, filters: @q)
set_focused_record(transactions_query, params[:focused_record_id], default_per_page: 50)
base_scope = @search.transactions_scope
.reverse_chronological
.includes(
{ entry: :account },
:category, :merchant, :tags,
:transfer_as_inflow, :transfer_as_outflow
)
# ------------------------------------------------------------------
# Cache the expensive includes & pagination block so the DB work only
# runs when either the query params change *or* any entry has been
# updated for the current family.
# ------------------------------------------------------------------
@pagy, @transactions = pagy(base_scope, limit: per_page, params: ->(p) { p.except(:focused_record_id) })
latest_update_ts = Current.family.entries.maximum(:updated_at)&.utc&.to_i || 0
items_per_page = (params[:per_page].presence || default_params[:per_page]).to_i
items_per_page = 1 if items_per_page <= 0
current_page = (params[:page].presence || default_params[:page]).to_i
current_page = 1 if current_page <= 0
# Build a compact cache digest: sanitized filters + page info + a
# token that changes on updates *or* deletions.
entries_changed_token = [ latest_update_ts, Current.family.entries.count ].join(":")
digest_source = {
q: @q, # processed & sanitised search params
page: current_page, # requested page number
per: items_per_page, # page size
tok: entries_changed_token
}.to_json
cache_key = Current.family.build_cache_key(
"transactions_idx_#{Digest::MD5.hexdigest(digest_source)}"
)
cache_data = Rails.cache.fetch(cache_key, expires_in: 30.minutes) do
current_page_i = current_page
# Initial query
offset = (current_page_i - 1) * items_per_page
ids = transactions_query
.reverse_chronological
.limit(items_per_page)
.offset(offset)
.pluck(:id)
total_count = transactions_query.count
if ids.empty? && total_count.positive? && current_page_i > 1
current_page_i = (total_count.to_f / items_per_page).ceil
offset = (current_page_i - 1) * items_per_page
ids = transactions_query
.reverse_chronological
.limit(items_per_page)
.offset(offset)
.pluck(:id)
end
{ ids: ids, total_count: total_count, current_page: current_page_i }
# No performance penalty by default. Only runs queries if the record is set.
if params[:focused_record_id].present?
set_focused_record(base_scope, params[:focused_record_id], default_per_page: per_page)
end
ids = cache_data[:ids]
total_count = cache_data[:total_count]
current_page = cache_data[:current_page]
# Build Pagy object (this part is cheap done *after* potential
# page fallback so the pagination UI reflects the adjusted page
# number).
@pagy = Pagy.new(
count: total_count,
page: current_page,
items: items_per_page,
params: ->(p) { p.except(:focused_record_id) }
)
# Fetch the transactions in the cached order
@transactions = Current.family.transactions
.active
.where(id: ids)
.includes(
{ entry: :account },
:category, :merchant, :tags,
transfer_as_outflow: { inflow_transaction: { entry: :account } },
transfer_as_inflow: { outflow_transaction: { entry: :account } }
)
# Preserve the order defined by `ids`
@transactions = ids.map { |id| @transactions.detect { |t| t.id == id } }.compact
@totals = Current.family.income_statement.totals(transactions_scope: transactions_query)
end
def clear_filter
@ -124,6 +49,10 @@ class TransactionsController < ApplicationController
end
updated_params["q"] = q_params.presence
# Add flag to indicate filters were explicitly cleared
updated_params["filter_cleared"] = "1" if updated_params["q"].blank?
Current.session.update!(prev_transaction_page_params: updated_params)
redirect_to transactions_path(updated_params)
@ -185,6 +114,10 @@ class TransactionsController < ApplicationController
end
private
def per_page
params[:per_page].to_i.positive? ? params[:per_page].to_i : 50
end
def needs_rule_notification?(transaction)
return false if Current.user.rule_prompts_disabled
@ -200,7 +133,7 @@ class TransactionsController < ApplicationController
def entry_params
entry_params = params.require(:entry).permit(
:name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,
entryable_attributes: [ :id, :category_id, :merchant_id, { tag_ids: [] } ]
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, { tag_ids: [] } ]
)
nature = entry_params.delete(:nature)
@ -217,7 +150,8 @@ class TransactionsController < ApplicationController
cleaned_params = params.fetch(:q, {})
.permit(
:start_date, :end_date, :search, :amount,
:amount_operator, accounts: [], account_ids: [],
:amount_operator, :active_accounts_only,
accounts: [], account_ids: [],
categories: [], merchants: [], types: [], tags: []
)
.to_h
@ -225,35 +159,9 @@ class TransactionsController < ApplicationController
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
# -------------------------------------------------------------------
# Performance optimisation
# -------------------------------------------------------------------
# When a user lands on the Transactions page without an explicit date
# filter, the previous behaviour queried *all* historical transactions
# for the family. For large datasets this results in very expensive
# SQL (as shown in Skylight) particularly the aggregation queries
# used for @totals. To keep the UI responsive while still showing a
# sensible period of activity, we fall back to the user's preferred
# default period (stored on User#default_period, defaulting to
# "last_30_days") when **no** date filters have been supplied.
#
# This effectively changes the default view from "all-time" to a
# rolling window, dramatically reducing the rows scanned / grouped in
# Postgres without impacting the UX (the user can always clear the
# filter).
# -------------------------------------------------------------------
if cleaned_params[:start_date].blank? && cleaned_params[:end_date].blank?
period_key = Current.user&.default_period.presence || "last_30_days"
begin
period = Period.from_key(period_key)
cleaned_params[:start_date] = period.start_date
cleaned_params[:end_date] = period.end_date
rescue Period::InvalidKeyError
# Fallback should never happen but keeps things safe.
cleaned_params[:start_date] = 30.days.ago.to_date
cleaned_params[:end_date] = Date.current
end
# Only add default start_date if params are blank AND filters weren't explicitly cleared
if cleaned_params.blank? && params[:filter_cleared].blank?
cleaned_params[:start_date] = 30.days.ago.to_date
end
cleaned_params
@ -263,9 +171,9 @@ class TransactionsController < ApplicationController
if should_restore_params?
params_to_restore = {}
params_to_restore[:q] = stored_params["q"].presence || default_params[:q]
params_to_restore[:page] = stored_params["page"].presence || default_params[:page]
params_to_restore[:per_page] = stored_params["per_page"].presence || default_params[:per_page]
params_to_restore[:q] = stored_params["q"].presence || {}
params_to_restore[:page] = stored_params["page"].presence || 1
params_to_restore[:per_page] = stored_params["per_page"].presence || 50
redirect_to transactions_path(params_to_restore)
else
@ -286,12 +194,4 @@ class TransactionsController < ApplicationController
def stored_params
Current.session.prev_transaction_page_params
end
def default_params
{
q: {},
page: 1,
per_page: 50
}
end
end

View file

@ -8,7 +8,12 @@ class TransferMatchesController < ApplicationController
def create
@transfer = build_transfer
@transfer.save!
Transfer.transaction do
@transfer.save!
@transfer.outflow_transaction.update!(kind: Transfer.kind_for_account(@transfer.outflow_transaction.entry.account))
@transfer.inflow_transaction.update!(kind: "funds_movement")
end
@transfer.sync_account_later
redirect_back_or_to transactions_path, notice: "Transfer created"

View file

@ -1,5 +1,7 @@
class TransfersController < ApplicationController
before_action :set_transfer, only: %i[destroy show update]
include StreamExtensions
before_action :set_transfer, only: %i[show destroy update]
def new
@transfer = Transfer.new
@ -10,25 +12,19 @@ class TransfersController < ApplicationController
end
def create
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
@transfer = Transfer.from_accounts(
from_account: from_account,
to_account: to_account,
@transfer = Transfer::Creator.new(
family: Current.family,
source_account_id: transfer_params[:from_account_id],
destination_account_id: transfer_params[:to_account_id],
date: transfer_params[:date],
amount: transfer_params[:amount].to_d
)
if @transfer.save
@transfer.sync_account_later
flash[:notice] = t(".success")
).create
if @transfer.persisted?
success_message = "Transfer created"
respond_to do |format|
format.html { redirect_back_or_to transactions_path }
redirect_target_url = request.referer || transactions_path
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
format.html { redirect_back_or_to transactions_path, notice: success_message }
format.turbo_stream { stream_redirect_back_or_to transactions_path, notice: success_message }
end
else
render :new, status: :unprocessable_entity
@ -54,9 +50,11 @@ class TransfersController < ApplicationController
private
def set_transfer
@transfer = Transfer.find(params[:id])
raise ActiveRecord::RecordNotFound unless @transfer.belongs_to_family?(Current.family)
# Finds the transfer and ensures the family owns it
@transfer = Transfer
.where(id: params[:id])
.where(inflow_transaction_id: Current.family.transactions.select(:id))
.first
end
def transfer_params

View file

@ -110,7 +110,13 @@ module ApplicationHelper
private
def calculate_total(item, money_method, negate)
items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? }
# Filter out transfer-type transactions from entries
# Only Entry objects have entryable transactions, Account objects don't
items = item.reject do |i|
i.is_a?(Entry) &&
i.entryable.is_a?(Transaction) &&
i.entryable.transfer?
end
total = items.sum(&money_method)
negate ? -total : total
end

View file

@ -4,6 +4,7 @@ module SettingsHelper
{ name: I18n.t("settings.settings_nav.preferences_label"), path: :settings_preferences_path },
{ name: I18n.t("settings.settings_nav.security_label"), path: :settings_security_path },
{ name: I18n.t("settings.settings_nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
{ name: "API Key", path: :settings_api_key_path },
{ name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path, condition: :not_self_hosted? },
{ name: I18n.t("settings.settings_nav.accounts_label"), path: :accounts_path },
{ name: I18n.t("settings.settings_nav.imports_label"), path: :imports_path },

View file

@ -11,6 +11,8 @@ class ImportMarketDataJob < ApplicationJob
queue_as :scheduled
def perform(opts)
return if Rails.env.development?
opts = opts.symbolize_keys
mode = opts.fetch(:mode, :full)
clear_cache = opts.fetch(:clear_cache, false)

View file

@ -2,6 +2,8 @@ class SecurityHealthCheckJob < ApplicationJob
queue_as :scheduled
def perform
return if Rails.env.development?
Security::HealthChecker.check_all
end
end

View file

@ -13,13 +13,6 @@ class Account::Syncer
def perform_post_sync
account.family.auto_match_transfers!
# Warm IncomeStatement caches so subsequent requests are fast
# TODO: this is a temporary solution to speed up pages. Long term we'll throw a materialized view / pre-computed table
# in for family stats.
income_statement = IncomeStatement.new(account.family)
Rails.logger.info("Warming IncomeStatement caches")
income_statement.warm_caches!
end
private

94
app/models/api_key.rb Normal file
View file

@ -0,0 +1,94 @@
class ApiKey < ApplicationRecord
belongs_to :user
# Use Rails built-in encryption for secure storage
encrypts :display_key, deterministic: true
# Constants
SOURCES = [ "web", "mobile" ].freeze
# Validations
validates :display_key, presence: true, uniqueness: true
validates :name, presence: true
validates :scopes, presence: true
validates :source, presence: true, inclusion: { in: SOURCES }
validate :scopes_not_empty
validate :one_active_key_per_user_per_source, on: :create
# Callbacks
before_validation :set_display_key
# Scopes
scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
# Class methods
def self.find_by_value(plain_key)
return nil unless plain_key
# Find by encrypted display_key (deterministic encryption allows querying)
find_by(display_key: plain_key)&.tap do |api_key|
return api_key if api_key.active?
end
end
def self.generate_secure_key
SecureRandom.hex(32)
end
# Instance methods
def active?
!revoked? && !expired?
end
def revoked?
revoked_at.present?
end
def expired?
expires_at.present? && expires_at < Time.current
end
def key_matches?(plain_key)
display_key == plain_key
end
def revoke!
update!(revoked_at: Time.current)
end
def update_last_used!
update_column(:last_used_at, Time.current)
end
# Get the plain text API key for display (automatically decrypted by Rails)
def plain_key
display_key
end
# Temporarily store the plain key for creation flow
attr_accessor :key
private
def set_display_key
if key.present?
self.display_key = key
end
end
def scopes_not_empty
if scopes.blank? || (scopes.is_a?(Array) && (scopes.empty? || scopes.all?(&:blank?)))
errors.add(:scopes, "must include at least one permission")
elsif scopes.is_a?(Array) && scopes.length > 1
errors.add(:scopes, "can only have one permission level")
elsif scopes.is_a?(Array) && !%w[read read_write].include?(scopes.first)
errors.add(:scopes, "must be either 'read' or 'read_write'")
end
end
def one_active_key_per_user_per_source
if user&.api_keys&.active&.where(source: source)&.where&.not(id: id)&.exists?
errors.add(:user, "can only have one active API key per source (#{source})")
end
end
end

View file

@ -163,7 +163,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
category: txn.category&.name,
merchant: txn.merchant&.name,
tags: txn.tags.map(&:name),
is_transfer: txn.transfer.present?
is_transfer: txn.transfer?
}
end

View file

@ -91,6 +91,7 @@ class Family < ApplicationRecord
entries.order(:date).first&.date || Date.current
end
# Used for invalidating family / balance sheet related aggregation queries
def build_cache_key(key, invalidate_on_data_updates: false)
# Our data sync process updates this timestamp whenever any family account successfully completes a data update.
# By including it in the cache key, we can expire caches every time family account data changes.
@ -103,6 +104,14 @@ class Family < ApplicationRecord
].compact.join("_")
end
# Used for invalidating entry related aggregation queries
def entries_cache_version
@entries_cache_version ||= begin
ts = entries.maximum(:updated_at)
ts.present? ? ts.to_i : 0
end
end
def self_hoster?
Rails.application.config.app_mode.self_hosted?
end

View file

@ -65,6 +65,9 @@ module Family::AutoTransferMatchable
outflow_transaction_id: match.outflow_transaction_id,
)
Transaction.find(match.inflow_transaction_id).update!(kind: "funds_movement")
Transaction.find(match.outflow_transaction_id).update!(kind: Transfer.kind_for_account(Transaction.find(match.outflow_transaction_id).entry.account))
used_transaction_ids << match.inflow_transaction_id
used_transaction_ids << match.outflow_transaction_id
end

View file

@ -20,8 +20,7 @@ class IncomeStatement
ScopeTotals.new(
transactions_count: result.sum(&:transactions_count),
income_money: Money.new(total_income, family.currency),
expense_money: Money.new(total_expense, family.currency),
missing_exchange_rates?: result.any?(&:missing_exchange_rates?)
expense_money: Money.new(total_expense, family.currency)
)
end
@ -53,16 +52,9 @@ class IncomeStatement
family_stats(interval: interval).find { |stat| stat.classification == "income" }&.median || 0
end
def warm_caches!(interval: "month")
totals
family_stats(interval: interval)
category_stats(interval: interval)
nil
end
private
ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money, :missing_exchange_rates?)
PeriodTotal = Data.define(:classification, :total, :currency, :missing_exchange_rates?, :category_totals)
ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money)
PeriodTotal = Data.define(:classification, :total, :currency, :category_totals)
CategoryTotal = Data.define(:category, :total, :currency, :weight)
def categories
@ -102,7 +94,6 @@ class IncomeStatement
classification: classification,
total: category_totals.reject { |ct| ct.category.subcategory? }.sum(&:total),
currency: family.currency,
missing_exchange_rates?: totals.any?(&:missing_exchange_rates?),
category_totals: category_totals
)
end
@ -110,14 +101,14 @@ class IncomeStatement
def family_stats(interval: "month")
@family_stats ||= {}
@family_stats[interval] ||= Rails.cache.fetch([
"income_statement", "family_stats", family.id, interval, entries_cache_version
"income_statement", "family_stats", family.id, interval, family.entries_cache_version
]) { FamilyStats.new(family, interval:).call }
end
def category_stats(interval: "month")
@category_stats ||= {}
@category_stats[interval] ||= Rails.cache.fetch([
"income_statement", "category_stats", family.id, interval, entries_cache_version
"income_statement", "category_stats", family.id, interval, family.entries_cache_version
]) { CategoryStats.new(family, interval:).call }
end
@ -125,24 +116,11 @@ class IncomeStatement
sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql)
Rails.cache.fetch([
"income_statement", "totals_query", family.id, sql_hash, entries_cache_version
"income_statement", "totals_query", family.id, sql_hash, family.entries_cache_version
]) { Totals.new(family, transactions_scope: transactions_scope).call }
end
def monetizable_currency
family.currency
end
# Returns a monotonically increasing integer based on the most recent
# update to any Entry that belongs to the family. Incorporated into cache
# keys so they expire automatically on data changes.
def entries_cache_version
@entries_cache_version ||= begin
ts = Entry.joins(:account)
.where(accounts: { family_id: family.id })
.maximum(:updated_at)
ts.present? ? ts.to_i : 0
end
end
end

View file

@ -1,43 +0,0 @@
module IncomeStatement::BaseQuery
private
def base_query_sql(family:, interval:, transactions_scope:)
sql = <<~SQL
SELECT
c.id as category_id,
c.parent_id as parent_category_id,
date_trunc(:interval, ae.date) as date,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
SUM(ae.amount * COALESCE(er.rate, 1)) as total,
COUNT(ae.id) as transactions_count,
BOOL_OR(ae.currency <> :target_currency AND er.rate IS NULL) as missing_exchange_rates
FROM (#{transactions_scope.to_sql}) at
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
LEFT JOIN categories c ON c.id = at.category_id
LEFT JOIN (
SELECT t.*, t.id as transfer_id, a.accountable_type
FROM transfers t
JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id
AND ae.entryable_type = 'Transaction'
JOIN accounts a ON a.id = ae.account_id
) transfer_info ON (
transfer_info.inflow_transaction_id = at.id OR
transfer_info.outflow_transaction_id = at.id
)
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE (
transfer_info.transfer_id IS NULL OR
(ae.amount > 0 AND transfer_info.accountable_type = 'Loan')
)
GROUP BY 1, 2, 3, 4
SQL
ActiveRecord::Base.sanitize_sql_array([
sql,
{ target_currency: family.currency, interval: interval }
])
end
end

View file

@ -1,40 +1,62 @@
class IncomeStatement::CategoryStats
include IncomeStatement::BaseQuery
def initialize(family, interval: "month")
@family = family
@interval = interval
end
def call
ActiveRecord::Base.connection.select_all(query_sql).map do |row|
ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row|
StatRow.new(
category_id: row["category_id"],
classification: row["classification"],
median: row["median"],
avg: row["avg"],
missing_exchange_rates?: row["missing_exchange_rates"]
avg: row["avg"]
)
end
end
private
StatRow = Data.define(:category_id, :classification, :median, :avg, :missing_exchange_rates?)
StatRow = Data.define(:category_id, :classification, :median, :avg)
def sanitized_query_sql
ActiveRecord::Base.sanitize_sql_array([
query_sql,
{
target_currency: @family.currency,
interval: @interval,
family_id: @family.id
}
])
end
def query_sql
base_sql = base_query_sql(family: @family, interval: @interval, transactions_scope: @family.transactions.active)
<<~SQL
WITH base_totals AS (
#{base_sql}
WITH period_totals AS (
SELECT
c.id as category_id,
date_trunc(:interval, ae.date) as period,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
SUM(ae.amount * COALESCE(er.rate, 1)) as total
FROM transactions t
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction'
JOIN accounts a ON a.id = ae.account_id
LEFT JOIN categories c ON c.id = t.category_id
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE a.family_id = :family_id
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND ae.excluded = false
GROUP BY c.id, period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
)
SELECT
category_id,
classification,
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
ABS(AVG(total)) as avg,
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
FROM base_totals
category_id,
classification,
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
ABS(AVG(total)) as avg
FROM period_totals
GROUP BY category_id, classification;
SQL
end

View file

@ -1,46 +1,58 @@
class IncomeStatement::FamilyStats
include IncomeStatement::BaseQuery
def initialize(family, interval: "month")
@family = family
@interval = interval
end
def call
ActiveRecord::Base.connection.select_all(query_sql).map do |row|
ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row|
StatRow.new(
classification: row["classification"],
median: row["median"],
avg: row["avg"],
missing_exchange_rates?: row["missing_exchange_rates"]
avg: row["avg"]
)
end
end
private
StatRow = Data.define(:classification, :median, :avg, :missing_exchange_rates?)
StatRow = Data.define(:classification, :median, :avg)
def sanitized_query_sql
ActiveRecord::Base.sanitize_sql_array([
query_sql,
{
target_currency: @family.currency,
interval: @interval,
family_id: @family.id
}
])
end
def query_sql
base_sql = base_query_sql(family: @family, interval: @interval, transactions_scope: @family.transactions.active)
<<~SQL
WITH base_totals AS (
#{base_sql}
), aggregated_totals AS (
WITH period_totals AS (
SELECT
date,
classification,
SUM(total) as total,
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
FROM base_totals
GROUP BY date, classification
date_trunc(:interval, ae.date) as period,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
SUM(ae.amount * COALESCE(er.rate, 1)) as total
FROM transactions t
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction'
JOIN accounts a ON a.id = ae.account_id
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE a.family_id = :family_id
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND ae.excluded = false
GROUP BY period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
)
SELECT
classification,
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
ABS(AVG(total)) as avg,
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
FROM aggregated_totals
classification,
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
ABS(AVG(total)) as avg
FROM period_totals
GROUP BY classification;
SQL
end

View file

@ -1,6 +1,4 @@
class IncomeStatement::Totals
include IncomeStatement::BaseQuery
def initialize(family, transactions_scope:)
@family = family
@transactions_scope = transactions_scope
@ -13,31 +11,48 @@ class IncomeStatement::Totals
category_id: row["category_id"],
classification: row["classification"],
total: row["total"],
transactions_count: row["transactions_count"],
missing_exchange_rates?: row["missing_exchange_rates"]
transactions_count: row["transactions_count"]
)
end
end
private
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count, :missing_exchange_rates?)
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count)
def query_sql
base_sql = base_query_sql(family: @family, interval: "day", transactions_scope: @transactions_scope)
ActiveRecord::Base.sanitize_sql_array([
optimized_query_sql,
sql_params
])
end
# OPTIMIZED: Direct SUM aggregation without unnecessary time bucketing
# Eliminates CTE and intermediate date grouping for maximum performance
def optimized_query_sql
<<~SQL
WITH base_totals AS (
#{base_sql}
)
SELECT
parent_category_id,
category_id,
classification,
ABS(SUM(total)) as total,
BOOL_OR(missing_exchange_rates) as missing_exchange_rates,
SUM(transactions_count) as transactions_count
FROM base_totals
GROUP BY 1, 2, 3;
c.id as category_id,
c.parent_id as parent_category_id,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
COUNT(ae.id) as transactions_count
FROM (#{@transactions_scope.to_sql}) at
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
LEFT JOIN categories c ON c.id = at.category_id
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND ae.excluded = false
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END;
SQL
end
def sql_params
{
target_currency: @family.currency
}
end
end

View file

@ -0,0 +1,55 @@
class MobileDevice < ApplicationRecord
belongs_to :user
belongs_to :oauth_application, class_name: "Doorkeeper::Application", optional: true
validates :device_id, presence: true, uniqueness: { scope: :user_id }
validates :device_name, presence: true
validates :device_type, presence: true, inclusion: { in: %w[ios android] }
before_validation :set_last_seen_at, on: :create
scope :active, -> { where("last_seen_at > ?", 90.days.ago) }
def active?
last_seen_at > 90.days.ago
end
def update_last_seen!
update_column(:last_seen_at, Time.current)
end
def create_oauth_application!
return oauth_application if oauth_application.present?
app = Doorkeeper::Application.create!(
name: "Mobile App - #{device_id}",
redirect_uri: "maybe://oauth/callback", # Custom scheme for mobile
scopes: "read_write", # Use the configured scope
confidential: false # Public client for mobile
)
# Store the association
update!(oauth_application: app)
app
end
def active_tokens
return Doorkeeper::AccessToken.none unless oauth_application
Doorkeeper::AccessToken
.where(application: oauth_application)
.where(resource_owner_id: user_id)
.where(revoked_at: nil)
.where("expires_in IS NULL OR created_at + expires_in * interval '1 second' > ?", Time.current)
end
def revoke_all_tokens!
active_tokens.update_all(revoked_at: Time.current)
end
private
def set_last_seen_at
self.last_seen_at ||= Time.current
end
end

View file

@ -53,7 +53,7 @@ module Security::Provided
price = response.data
Security::Price.find_or_create_by!(
security_id: price.security.id,
security_id: self.id,
date: price.date,
price: price.price,
currency: price.currency

View file

@ -0,0 +1,113 @@
class Trade::CreateForm
include ActiveModel::Model
attr_accessor :account, :date, :amount, :currency, :qty,
:price, :ticker, :manual_ticker, :type, :transfer_account_id
# Either creates a trade, transaction, or transfer based on type
# Returns the model, regardless of success or failure
def create
case type
when "buy", "sell"
create_trade
when "interest"
create_interest_income
when "deposit", "withdrawal"
create_transfer
end
end
private
# Users can either look up a ticker from our provider (Synth) or enter a manual, "offline" ticker (that we won't fetch prices for)
def security
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
Security::Resolver.new(
ticker_symbol,
exchange_operating_mic: exchange_operating_mic
).resolve
end
def create_trade
prefix = type == "sell" ? "Sell " : "Buy "
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
signed_qty = type == "sell" ? -qty.to_d : qty.to_d
signed_amount = signed_qty * price.to_d
trade_entry = account.entries.new(
name: trade_name,
date: date,
amount: signed_amount,
currency: currency,
entryable: Trade.new(
qty: signed_qty,
price: price,
currency: currency,
security: security
)
)
if trade_entry.save
trade_entry.lock_saved_attributes!
account.sync_later
end
trade_entry
end
def create_interest_income
signed_amount = amount.to_d * -1
entry = account.entries.build(
name: "Interest payment",
date: date,
amount: signed_amount,
currency: currency,
entryable: Transaction.new
)
if entry.save
entry.lock_saved_attributes!
account.sync_later
end
entry
end
def create_transfer
if transfer_account_id.present?
from_account_id = type == "withdrawal" ? account.id : transfer_account_id
to_account_id = type == "withdrawal" ? transfer_account_id : account.id
Transfer::Creator.new(
family: account.family,
source_account_id: from_account_id,
destination_account_id: to_account_id,
date: date,
amount: amount
).create
else
create_unlinked_transfer
end
end
# If user doesn't provide the reciprocal account, it's a regular transaction
def create_unlinked_transfer
signed_amount = type == "deposit" ? amount.to_d * -1 : amount.to_d
entry = account.entries.build(
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
date: date,
amount: signed_amount,
currency: currency,
entryable: Transaction.new
)
if entry.save
entry.lock_saved_attributes!
account.sync_later
end
entry
end
end

View file

@ -1,137 +0,0 @@
class TradeBuilder
include ActiveModel::Model
attr_accessor :account, :date, :amount, :currency, :qty,
:price, :ticker, :manual_ticker, :type, :transfer_account_id
attr_reader :buildable
def initialize(attributes = {})
super
@buildable = set_buildable
end
def save
buildable.save
end
def lock_saved_attributes!
if buildable.is_a?(Transfer)
buildable.inflow_transaction.entry.lock_saved_attributes!
buildable.outflow_transaction.entry.lock_saved_attributes!
else
buildable.lock_saved_attributes!
end
end
def entryable
return nil if buildable.is_a?(Transfer)
buildable.entryable
end
def errors
buildable.errors
end
def sync_account_later
buildable.sync_account_later
end
private
def set_buildable
case type
when "buy", "sell"
build_trade
when "deposit", "withdrawal"
build_transfer
when "interest"
build_interest
else
raise "Unknown trade type: #{type}"
end
end
def build_trade
prefix = type == "sell" ? "Sell " : "Buy "
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
account.entries.new(
name: trade_name,
date: date,
amount: signed_amount,
currency: currency,
entryable: Trade.new(
qty: signed_qty,
price: price,
currency: currency,
security: security
)
)
end
def build_transfer
transfer_account = family.accounts.find(transfer_account_id) if transfer_account_id.present?
if transfer_account
from_account = type == "withdrawal" ? account : transfer_account
to_account = type == "withdrawal" ? transfer_account : account
Transfer.from_accounts(
from_account: from_account,
to_account: to_account,
date: date,
amount: signed_amount
)
else
account.entries.build(
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
date: date,
amount: signed_amount,
currency: currency,
entryable: Transaction.new
)
end
end
def build_interest
account.entries.build(
name: "Interest payment",
date: date,
amount: signed_amount,
currency: currency,
entryable: Transaction.new
)
end
def signed_qty
return nil unless type.in?([ "buy", "sell" ])
type == "sell" ? -qty.to_d : qty.to_d
end
def signed_amount
case type
when "buy", "sell"
signed_qty * price.to_d
when "deposit", "withdrawal"
type == "deposit" ? -amount.to_d : amount.to_d
when "interest"
amount.to_d * -1
end
end
def family
account.family
end
# Users can either look up a ticker from our provider (Synth) or enter a manual, "offline" ticker (that we won't fetch prices for)
def security
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
Security::Resolver.new(
ticker_symbol,
exchange_operating_mic: exchange_operating_mic
).resolve
end
end

View file

@ -9,10 +9,17 @@ class Transaction < ApplicationRecord
accepts_nested_attributes_for :taggings, allow_destroy: true
class << self
def search(params)
Search.new(params).build_query(all)
end
enum :kind, {
standard: "standard", # A regular transaction, included in budget analytics
funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics
cc_payment: "cc_payment", # A CC payment, excluded from budget analytics (CC payments offset the sum of expense transactions)
loan_payment: "loan_payment", # A payment to a Loan account, treated as an expense in budgets
one_time: "one_time" # A one-time expense/income, excluded from budget analytics
}
# Overarching grouping method for all transfer-type transactions
def transfer?
funds_movement? || cc_payment? || loan_payment?
end
def set_category!(category)

View file

@ -13,45 +13,87 @@ class Transaction::Search
attribute :categories, array: true
attribute :merchants, array: true
attribute :tags, array: true
attribute :active_accounts_only, :boolean, default: true
def build_query(scope)
query = scope.joins(entry: :account)
.joins(transfer_join)
attr_reader :family
query = apply_category_filter(query, categories)
query = apply_type_filter(query, types)
query = apply_merchant_filter(query, merchants)
query = apply_tag_filter(query, tags)
query = EntrySearch.apply_search_filter(query, search)
query = EntrySearch.apply_date_filters(query, start_date, end_date)
query = EntrySearch.apply_amount_filter(query, amount, amount_operator)
query = EntrySearch.apply_accounts_filter(query, accounts, account_ids)
def initialize(family, filters: {})
@family = family
super(filters)
end
query
def transactions_scope
@transactions_scope ||= begin
# This already joins entries + accounts. To avoid expensive double-joins, don't join them again (causes full table scan)
query = family.transactions
query = apply_active_accounts_filter(query, active_accounts_only)
query = apply_category_filter(query, categories)
query = apply_type_filter(query, types)
query = apply_merchant_filter(query, merchants)
query = apply_tag_filter(query, tags)
query = EntrySearch.apply_search_filter(query, search)
query = EntrySearch.apply_date_filters(query, start_date, end_date)
query = EntrySearch.apply_amount_filter(query, amount, amount_operator)
query = EntrySearch.apply_accounts_filter(query, accounts, account_ids)
query
end
end
# Computes totals for the specific search
def totals
@totals ||= begin
Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do
result = transactions_scope
.select(
"COALESCE(SUM(CASE WHEN entries.amount >= 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
"COALESCE(SUM(CASE WHEN entries.amount < 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
"COUNT(entries.id) as transactions_count"
)
.joins(
ActiveRecord::Base.sanitize_sql_array([
"LEFT JOIN exchange_rates er ON (er.date = entries.date AND er.from_currency = entries.currency AND er.to_currency = ?)",
family.currency
])
)
.take
Totals.new(
count: result.transactions_count.to_i,
income_money: Money.new(result.income_total.to_i, family.currency),
expense_money: Money.new(result.expense_total.to_i, family.currency)
)
end
end
end
def cache_key_base
[
family.id,
Digest::SHA256.hexdigest(attributes.sort.to_h.to_json), # cached by filters
family.entries_cache_version
].join("/")
end
private
def transfer_join
<<~SQL
LEFT JOIN (
SELECT t.*, t.id as transfer_id, a.accountable_type
FROM transfers t
JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id
AND ae.entryable_type = 'Transaction'
JOIN accounts a ON a.id = ae.account_id
) transfer_info ON (
transfer_info.inflow_transaction_id = transactions.id OR
transfer_info.outflow_transaction_id = transactions.id
)
SQL
Totals = Data.define(:count, :income_money, :expense_money)
def apply_active_accounts_filter(query, active_accounts_only_filter)
if active_accounts_only_filter
query.where(accounts: { is_active: true })
else
query
end
end
def apply_category_filter(query, categories)
return query unless categories.present?
query = query.left_joins(:category).where(
"categories.name IN (?) OR (
categories.id IS NULL AND (transfer_info.transfer_id IS NULL OR transfer_info.accountable_type = 'Loan')
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment'))
)",
categories
)
@ -67,7 +109,7 @@ class Transaction::Search
return query unless types.present?
return query if types.sort == [ "expense", "income", "transfer" ]
transfer_condition = "transfer_info.transfer_id IS NOT NULL"
transfer_condition = "transactions.kind IN ('funds_movement', 'cc_payment', 'loan_payment')"
expense_condition = "entries.amount >= 0"
income_condition = "entries.amount <= 0"

View file

@ -14,10 +14,6 @@ module Transaction::Transferable
transfer_as_inflow || transfer_as_outflow
end
def transfer?
transfer.present?
end
def transfer_match_candidates
candidates_scope = if self.entry.amount.negative?
family_matches_scope.where("inflow_candidates.entryable_id = ?", self.id)

View file

@ -13,34 +13,14 @@ class Transfer < ApplicationRecord
validate :transfer_has_same_family
class << self
def from_accounts(from_account:, to_account:, date:, amount:)
# Attempt to convert the amount to the to_account's currency.
# If the conversion fails, use the original amount.
converted_amount = begin
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
rescue Money::ConversionError
Money.new(amount.abs, from_account.currency)
def kind_for_account(account)
if account.loan?
"loan_payment"
elsif account.liability?
"cc_payment"
else
"funds_movement"
end
new(
inflow_transaction: Transaction.new(
entry: to_account.entries.build(
amount: converted_amount.amount.abs * -1,
currency: converted_amount.currency.iso_code,
date: date,
name: "Transfer from #{from_account.name}",
)
),
outflow_transaction: Transaction.new(
entry: from_account.entries.build(
amount: amount.abs,
currency: from_account.currency,
date: date,
name: "Transfer to #{to_account.name}",
)
),
status: "confirmed"
)
end
end
@ -51,19 +31,28 @@ class Transfer < ApplicationRecord
end
end
# Once transfer is destroyed, we need to mark the denormalized kind fields on the transactions
def destroy!
Transfer.transaction do
inflow_transaction.update!(kind: "standard")
outflow_transaction.update!(kind: "standard")
super
end
end
def confirm!
update!(status: "confirmed")
end
def date
inflow_transaction.entry.date
end
def sync_account_later
inflow_transaction&.entry&.sync_account_later
outflow_transaction&.entry&.sync_account_later
end
def belongs_to_family?(family)
family.transactions.include?(inflow_transaction)
end
def to_account
inflow_transaction&.entry&.account
end
@ -89,6 +78,24 @@ class Transfer < ApplicationRecord
to_account&.liability?
end
def loan_payment?
outflow_transaction&.kind == "loan_payment"
end
def liability_payment?
outflow_transaction&.kind == "cc_payment"
end
def regular_transfer?
outflow_transaction&.kind == "funds_movement"
end
def transfer_type
return "loan_payment" if loan_payment?
return "liability_payment" if liability_payment?
"transfer"
end
def categorizable?
to_account&.accountable_type == "Loan"
end

View file

@ -0,0 +1,85 @@
class Transfer::Creator
def initialize(family:, source_account_id:, destination_account_id:, date:, amount:)
@family = family
@source_account = family.accounts.find(source_account_id) # early throw if not found
@destination_account = family.accounts.find(destination_account_id) # early throw if not found
@date = date
@amount = amount.to_d
end
def create
transfer = Transfer.new(
inflow_transaction: inflow_transaction,
outflow_transaction: outflow_transaction,
status: "confirmed"
)
if transfer.save
source_account.sync_later
destination_account.sync_later
end
transfer
end
private
attr_reader :family, :source_account, :destination_account, :date, :amount
def outflow_transaction
name = "#{name_prefix} to #{destination_account.name}"
Transaction.new(
kind: outflow_transaction_kind,
entry: source_account.entries.build(
amount: amount.abs,
currency: source_account.currency,
date: date,
name: name,
)
)
end
def inflow_transaction
name = "#{name_prefix} from #{source_account.name}"
Transaction.new(
kind: "funds_movement",
entry: destination_account.entries.build(
amount: inflow_converted_money.amount.abs * -1,
currency: destination_account.currency,
date: date,
name: name,
)
)
end
# If destination account has different currency, its transaction should show up as converted
# Future improvement: instead of a 1:1 conversion fallback, add a UI/UX flow for missing rates
def inflow_converted_money
Money.new(amount.abs, source_account.currency)
.exchange_to(
destination_account.currency,
date: date,
fallback_rate: 1.0
)
end
# The "expense" side of a transfer is treated different in analytics based on where it goes.
def outflow_transaction_kind
if destination_account.loan?
"loan_payment"
elsif destination_account.liability?
"cc_payment"
else
"funds_movement"
end
end
def name_prefix
if destination_account.liability?
"Payment"
else
"Transfer"
end
end
end

View file

@ -5,6 +5,8 @@ class User < ApplicationRecord
belongs_to :last_viewed_chat, class_name: "Chat", optional: true
has_many :sessions, dependent: :destroy
has_many :chats, dependent: :destroy
has_many :api_keys, dependent: :destroy
has_many :mobile_devices, dependent: :destroy
has_many :invitations, foreign_key: :inviter_id, dependent: :destroy
has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy
has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy

View file

@ -0,0 +1,85 @@
class ApiRateLimiter
# Rate limit tiers (requests per hour)
RATE_LIMITS = {
standard: 100,
premium: 1000,
enterprise: 10000
}.freeze
DEFAULT_TIER = :standard
def initialize(api_key)
@api_key = api_key
@redis = Redis.new
end
# Check if the API key has exceeded its rate limit
def rate_limit_exceeded?
current_count >= rate_limit
end
# Increment the request count for this API key
def increment_request_count!
key = redis_key
current_time = Time.current.to_i
window_start = (current_time / 3600) * 3600 # Hourly window
@redis.multi do |transaction|
# Use a sliding window with hourly buckets
transaction.hincrby(key, window_start.to_s, 1)
transaction.expire(key, 7200) # Keep data for 2 hours to handle sliding window
end
end
# Get current request count within the current hour
def current_count
key = redis_key
current_time = Time.current.to_i
window_start = (current_time / 3600) * 3600
count = @redis.hget(key, window_start.to_s)
count.to_i
end
# Get the rate limit for this API key's tier
def rate_limit
tier = determine_tier
RATE_LIMITS[tier]
end
# Calculate seconds until the rate limit resets
def reset_time
current_time = Time.current.to_i
next_window = ((current_time / 3600) + 1) * 3600
next_window - current_time
end
# Get detailed usage information
def usage_info
{
current_count: current_count,
rate_limit: rate_limit,
remaining: [ rate_limit - current_count, 0 ].max,
reset_time: reset_time,
tier: determine_tier
}
end
# Class method to get usage for an API key without incrementing
def self.usage_for(api_key)
new(api_key).usage_info
end
private
def redis_key
"api_rate_limit:#{@api_key.id}"
end
def determine_tier
# For now, all API keys are standard tier
# This can be extended later to support different tiers based on user subscription
# or API key configuration
DEFAULT_TIER
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
json.accounts @accounts do |account|
json.id account.id
json.name account.name
json.balance account.balance_money.format
json.currency account.currency
json.classification account.classification
json.account_type account.accountable_type.underscore
end
json.pagination do
json.page @pagy.page
json.per_page @per_page
json.total_count @pagy.count
json.total_pages @pagy.pages
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
json.id chat.id
json.title chat.title
json.error chat.error.present? ? chat.error : nil
json.created_at chat.created_at.iso8601
json.updated_at chat.updated_at.iso8601

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
json.chats @chats do |chat|
json.id chat.id
json.title chat.title
json.last_message_at chat.messages.ordered.first&.created_at&.iso8601
json.message_count chat.messages.count
json.error chat.error.present? ? chat.error : nil
json.created_at chat.created_at.iso8601
json.updated_at chat.updated_at.iso8601
end
json.pagination do
json.page @pagy.page
json.per_page @pagy.vars[:items]
json.total_count @pagy.count
json.total_pages @pagy.pages
end

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
json.partial! "chat", chat: @chat
json.messages @messages do |message|
json.id message.id
json.type message.type.underscore
json.role message.role
json.content message.content
json.model message.ai_model if message.type == "AssistantMessage"
json.created_at message.created_at.iso8601
json.updated_at message.updated_at.iso8601
# Include tool calls for assistant messages
if message.type == "AssistantMessage" && message.tool_calls.any?
json.tool_calls message.tool_calls do |tool_call|
json.id tool_call.id
json.function_name tool_call.function_name
json.function_arguments tool_call.function_arguments
json.function_result tool_call.function_result
json.created_at tool_call.created_at.iso8601
end
end
end
if @pagy
json.pagination do
json.page @pagy.page
json.per_page @pagy.vars[:items]
json.total_count @pagy.count
json.total_pages @pagy.pages
end
end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
json.id @message.id
json.chat_id @message.chat_id
json.type @message.type.underscore
json.role @message.role
json.content @message.content
json.model @message.ai_model if @message.type == "AssistantMessage"
json.created_at @message.created_at.iso8601
json.updated_at @message.updated_at.iso8601
# Note: AI response will be processed asynchronously
if @message.type == "UserMessage"
json.ai_response_status "pending"
json.ai_response_message "AI response is being generated"
end

View file

@ -0,0 +1,76 @@
# frozen_string_literal: true
json.id transaction.id
json.date transaction.entry.date
json.amount transaction.entry.amount_money.format
json.currency transaction.entry.currency
json.name transaction.entry.name
json.notes transaction.entry.notes
json.classification transaction.entry.classification
# Account information
json.account do
json.id transaction.entry.account.id
json.name transaction.entry.account.name
json.account_type transaction.entry.account.accountable_type.underscore
end
# Category information
if transaction.category.present?
json.category do
json.id transaction.category.id
json.name transaction.category.name
json.classification transaction.category.classification
json.color transaction.category.color
json.icon transaction.category.lucide_icon
end
else
json.category nil
end
# Merchant information
if transaction.merchant.present?
json.merchant do
json.id transaction.merchant.id
json.name transaction.merchant.name
end
else
json.merchant nil
end
# Tags
json.tags transaction.tags do |tag|
json.id tag.id
json.name tag.name
json.color tag.color
end
# Transfer information (if this transaction is part of a transfer)
if transaction.transfer.present?
json.transfer do
json.id transaction.transfer.id
json.amount transaction.transfer.amount_abs.format
json.currency transaction.transfer.inflow_transaction.entry.currency
# Other transaction in the transfer
if transaction.transfer.inflow_transaction == transaction
other_transaction = transaction.transfer.outflow_transaction
else
other_transaction = transaction.transfer.inflow_transaction
end
if other_transaction.present?
json.other_account do
json.id other_transaction.entry.account.id
json.name other_transaction.entry.account.name
json.account_type other_transaction.entry.account.accountable_type.underscore
end
end
end
else
json.transfer nil
end
# Additional metadata
json.created_at transaction.created_at.iso8601
json.updated_at transaction.updated_at.iso8601

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
json.transactions @transactions do |transaction|
json.partial! "transaction", transaction: transaction
end
json.pagination do
json.page @pagy.page
json.per_page @per_page
json.total_count @pagy.count
json.total_pages @pagy.pages
end

View file

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! "transaction", transaction: @transaction

View file

@ -9,81 +9,81 @@
class="bg-container placeholder:text-sm placeholder:text-secondary font-normal h-10 relative pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
data-list-filter-target="input"
data-action="list-filter#filter">
<%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
<%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
</div>
</div>
</div>
<div data-list-filter-target="list" class="flex flex-col gap-0.5 p-1.5 mt-0.5 mr-2 max-h-64 overflow-y-scroll scrollbar">
<div class="pb-2 pl-4 mr-2 text-secondary hidden" data-list-filter-target="emptyMessage">
<%= t(".no_categories") %>
</div>
<% if @categories.any? %>
<% Category::Group.for(@categories).each do |group| %>
<%= render "category/dropdowns/row", category: group.category %>
<div data-list-filter-target="list" class="flex flex-col gap-0.5 p-1.5 mt-0.5 mr-2 max-h-64 overflow-y-scroll scrollbar">
<div class="pb-2 pl-4 mr-2 text-secondary hidden" data-list-filter-target="emptyMessage">
<%= t(".no_categories") %>
</div>
<% if @categories.any? %>
<% Category::Group.for(@categories).each do |group| %>
<%= render "category/dropdowns/row", category: group.category %>
<% group.subcategories.each do |category| %>
<%= render "category/dropdowns/row", category: category %>
<% group.subcategories.each do |category| %>
<%= render "category/dropdowns/row", category: category %>
<% end %>
<% end %>
<% end %>
<% else %>
<div class="flex justify-center items-center py-12">
<div class="text-center flex flex-col items-center max-w-[500px]">
<p class="text-sm text-secondary font-normal mb-4"><%= t(".empty") %></p>
<% else %>
<div class="flex justify-center items-center py-12">
<div class="text-center flex flex-col items-center max-w-[500px]">
<p class="text-sm text-secondary font-normal mb-4"><%= t(".empty") %></p>
<%= render ButtonComponent.new(
<%= render ButtonComponent.new(
text: t(".bootstrap"),
variant: "outline",
href: bootstrap_categories_path,
method: :post,
data: { turbo_frame: :_top }) %>
</div>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
<%= render "shared/ruler", classes: "my-2" %>
<%= render "shared/ruler", classes: "my-2" %>
<div class="relative p-1.5 w-full">
<% if @transaction.category %>
<%= button_to transaction_path(@transaction.entry),
<div class="relative p-1.5 w-full">
<% if @transaction.category %>
<%= button_to transaction_path(@transaction.entry),
method: :patch,
data: { turbo_frame: dom_id(@transaction.entry) },
params: { entry: { entryable_type: "Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover" do %>
<%= icon("minus") %>
<%= icon("minus") %>
<%= t(".clear") %>
<%= t(".clear") %>
<% end %>
<% end %>
<% end %>
<% unless @transaction.transfer? %>
<%= link_to new_transaction_transfer_match_path(@transaction.entry),
<% unless @transaction.transfer? %>
<%= link_to new_transaction_transfer_match_path(@transaction.entry),
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover",
data: { turbo_frame: "modal" } do %>
<%= icon("refresh-cw") %>
<%= icon("refresh-cw") %>
<p>Match transfer/payment</p>
<p>Match transfer/payment</p>
<% end %>
<% end %>
<% end %>
<div class="flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2">
<div class="flex items-center gap-2">
<%= form_with url: transaction_path(@transaction.entry),
<div class="flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2">
<div class="flex items-center gap-2">
<%= form_with url: transaction_path(@transaction.entry),
method: :patch,
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field "entry[excluded]", value: !@transaction.entry.excluded %>
<%= f.check_box "entry[excluded]",
<%= f.hidden_field "entry[excluded]", value: !@transaction.entry.excluded %>
<%= f.check_box "entry[excluded]",
checked: @transaction.entry.excluded,
class: "checkbox checkbox--light",
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change" } %>
<% end %>
<% end %>
</div>
<p>One-time <%= @transaction.entry.amount.negative? ? "income" : "expense" %></p>
<span class="text-orange-500 ml-auto">
<%= icon("asterisk", color: "current") %>
</span>
</div>
<p>One-time <%= @transaction.entry.amount.negative? ? "income" : "expense" %></p>
<span class="text-orange-500 ml-auto">
<%= icon("asterisk", color: "current") %>
</span>
</div>
</div>
</div>
<% end %>
<% end %>

View file

@ -0,0 +1,6 @@
<%- submit_btn_css ||= "btn btn-link" %>
<%= form_tag oauth_application_path(application), method: :delete do %>
<%= submit_tag t("doorkeeper.applications.buttons.destroy"),
onclick: "return confirm('#{ t('doorkeeper.applications.confirmations.destroy') }')",
class: submit_btn_css %>
<% end %>

View file

@ -0,0 +1,59 @@
<%= form_for application, url: doorkeeper_submit_path(application), as: :doorkeeper_application, html: { role: "form" } do |f| %>
<% if application.errors.any? %>
<div class="alert alert-danger" data-alert><p><%= t("doorkeeper.applications.form.error") %></p></div>
<% end %>
<div class="form-group row">
<%= f.label :name, class: "col-sm-2 col-form-label font-weight-bold" %>
<div class="col-sm-10">
<%= f.text_field :name, class: "form-control #{ 'is-invalid' if application.errors[:name].present? }", required: true %>
<%= doorkeeper_errors_for application, :name %>
</div>
</div>
<div class="form-group row">
<%= f.label :redirect_uri, class: "col-sm-2 col-form-label font-weight-bold" %>
<div class="col-sm-10">
<%= f.text_area :redirect_uri, class: "form-control #{ 'is-invalid' if application.errors[:redirect_uri].present? }" %>
<%= doorkeeper_errors_for application, :redirect_uri %>
<span class="form-text text-secondary">
<%= t("doorkeeper.applications.help.redirect_uri") %>
</span>
<% if Doorkeeper.configuration.allow_blank_redirect_uri?(application) %>
<span class="form-text text-secondary">
<%= t("doorkeeper.applications.help.blank_redirect_uri") %>
</span>
<% end %>
</div>
</div>
<div class="form-group row">
<%= f.label :confidential, class: "col-sm-2 form-check-label font-weight-bold" %>
<div class="col-sm-10">
<%= f.check_box :confidential, class: "checkbox #{ 'is-invalid' if application.errors[:confidential].present? }" %>
<%= doorkeeper_errors_for application, :confidential %>
<span class="form-text text-secondary">
<%= t("doorkeeper.applications.help.confidential") %>
</span>
</div>
</div>
<div class="form-group row">
<%= f.label :scopes, class: "col-sm-2 col-form-label font-weight-bold" %>
<div class="col-sm-10">
<%= f.text_field :scopes, class: "form-control #{ 'has-error' if application.errors[:scopes].present? }" %>
<%= doorkeeper_errors_for application, :scopes %>
<span class="form-text text-secondary">
<%= t("doorkeeper.applications.help.scopes") %>
</span>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<%= f.submit t("doorkeeper.applications.buttons.submit"), class: "btn btn-primary" %>
<%= link_to t("doorkeeper.applications.buttons.cancel"), oauth_applications_path, class: "btn btn-secondary" %>
</div>
</div>
<% end %>

View file

@ -0,0 +1,5 @@
<div class="border-bottom mb-4">
<h1><%= t(".title") %></h1>
</div>
<%= render "form", application: @application %>

View file

@ -0,0 +1,38 @@
<div class="border-bottom mb-4">
<h1><%= t(".title") %></h1>
</div>
<p><%= link_to t(".new"), new_oauth_application_path, class: "btn btn-success" %></p>
<table class="table table-striped">
<thead>
<tr>
<th><%= t(".name") %></th>
<th><%= t(".callback_url") %></th>
<th><%= t(".confidential") %></th>
<th><%= t(".actions") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @applications.each do |application| %>
<tr id="application_<%= application.id %>">
<td class="align-middle">
<%= link_to application.name, oauth_application_path(application) %>
</td>
<td class="align-middle">
<%= simple_format(application.redirect_uri) %>
</td>
<td class="align-middle">
<%= application.confidential? ? t("doorkeeper.applications.index.confidentiality.yes") : t("doorkeeper.applications.index.confidentiality.no") %>
</td>
<td class="align-middle">
<%= link_to t("doorkeeper.applications.buttons.edit"), edit_oauth_application_path(application), class: "btn btn-link" %>
</td>
<td class="align-middle">
<%= render "delete_form", application: application %>
</td>
</tr>
<% end %>
</tbody>
</table>

View file

@ -0,0 +1,5 @@
<div class="border-bottom mb-4">
<h1><%= t(".title") %></h1>
</div>
<%= render "form", application: @application %>

View file

@ -0,0 +1,63 @@
<div class="border-bottom mb-4">
<h1><%= t(".title", name: @application.name) %></h1>
</div>
<div class="row">
<div class="col-md-8">
<h4><%= t(".application_id") %>:</h4>
<p><code class="bg-light" id="application_id"><%= @application.uid %></code></p>
<h4><%= t(".secret") %>:</h4>
<p>
<code class="bg-light" id="secret">
<% secret = flash[:application_secret].presence || @application.plaintext_secret %>
<% if secret.blank? && Doorkeeper.config.application_secret_hashed? %>
<span class="bg-light font-italic text-uppercase text-muted"><%= t(".secret_hashed") %></span>
<% else %>
<%= secret %>
<% end %>
</code>
</p>
<h4><%= t(".scopes") %>:</h4>
<p>
<code class="bg-light" id="scopes">
<% if @application.scopes.present? %>
<%= @application.scopes %>
<% else %>
<span class="bg-light font-italic text-uppercase text-muted"><%= t(".not_defined") %></span>
<% end %>
</code>
</p>
<h4><%= t(".confidential") %>:</h4>
<p><code class="bg-light" id="confidential"><%= @application.confidential? %></code></p>
<h4><%= t(".callback_urls") %>:</h4>
<% if @application.redirect_uri.present? %>
<table>
<% @application.redirect_uri.split.each do |uri| %>
<tr>
<td>
<code class="bg-light"><%= uri %></code>
</td>
<td>
<%= link_to t("doorkeeper.applications.buttons.authorize"), oauth_authorization_path(client_id: @application.uid, redirect_uri: uri, response_type: "code", scope: @application.scopes), class: "btn btn-success", target: "_blank" %>
</td>
</tr>
<% end %>
</table>
<% else %>
<span class="bg-light font-italic text-uppercase text-muted"><%= t(".not_defined") %></span>
<% end %>
</div>
<div class="col-md-4">
<h3><%= t(".actions") %></h3>
<p><%= link_to t("doorkeeper.applications.buttons.edit"), edit_oauth_application_path(@application), class: "btn btn-primary" %></p>
<p><%= render "delete_form", application: @application, submit_btn_css: "btn btn-danger" %></p>
</div>
</div>

View file

@ -0,0 +1,22 @@
<div class="bg-container rounded-xl p-6 space-y-6">
<div class="text-center space-y-2">
<div class="mx-auto w-12 h-12 rounded-full bg-destructive-surface flex items-center justify-center mb-4">
<%= icon("alert-circle", class: "w-6 h-6 text-destructive") %>
</div>
<h1 class="text-2xl font-medium text-primary"><%= t("doorkeeper.authorizations.error.title") %></h1>
</div>
<div class="bg-surface-inset rounded-lg p-4">
<p class="text-sm text-secondary">
<%= (local_assigns[:error_response] ? error_response : @pre_auth.error_response).body[:error_description] %>
</p>
</div>
<div class="text-center">
<%= render LinkComponent.new(
text: "Go back",
href: "javascript:history.back()",
variant: :secondary
) %>
</div>
</div>

View file

@ -0,0 +1,22 @@
<div class="bg-container rounded-xl p-6 space-y-6">
<div class="text-center space-y-2">
<div class="mx-auto w-12 h-12 rounded-full bg-surface-inset flex items-center justify-center mb-4">
<%= icon("loader-circle", class: "w-6 h-6 text-primary animate-spin") %>
</div>
<h1 class="text-2xl font-medium text-primary"><%= t(".title") %></h1>
<p class="text-sm text-secondary">Redirecting you back to the application...</p>
</div>
</div>
<% turbo_disabled = @pre_auth.redirect_uri&.start_with?("maybeapp://") || params[:display] == "mobile" %>
<%= form_tag @pre_auth.redirect_uri, method: :post, name: :redirect_form, authenticity_token: false, data: { turbo: !turbo_disabled } do %>
<% auth.body.compact.each do |key, value| %>
<%= hidden_field_tag key, value %>
<% end %>
<% end %>
<script>
window.onload = function () {
document.forms['redirect_form'].submit();
};
</script>

View file

@ -0,0 +1,76 @@
<% if params[:redirect_uri]&.start_with?('maybeapp://') || params[:display] == 'mobile' %>
<meta name="turbo-visit-control" content="reload">
<% end %>
<div class="bg-container rounded-xl p-6 space-y-6">
<div class="space-y-2 text-center">
<p class="text-sm text-secondary">
<%= raw t(".prompt", client_name: content_tag(:span, @pre_auth.client.name, class: "font-medium text-primary")) %>
</p>
</div>
<% if @pre_auth.scopes.count > 0 %>
<div class="bg-surface-inset rounded-lg p-4 space-y-3">
<p class="text-sm font-medium text-primary"><%= t(".able_to") %>:</p>
<ul class="space-y-2">
<% @pre_auth.scopes.each do |scope| %>
<li class="flex items-start gap-2 text-sm text-secondary">
<%= icon("check", class: "w-4 h-4 mt-0.5 text-success") %>
<span><%= t scope, scope: [:doorkeeper, :scopes] %></span>
</li>
<% end %>
</ul>
</div>
<% end %>
<div class="space-y-3">
<% turbo_disabled = params[:redirect_uri]&.start_with?("maybeapp://") || params[:display] == "mobile" %>
<%= form_tag oauth_authorization_path, method: :post, class: "w-full", data: { turbo: !turbo_disabled } do %>
<%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %>
<%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %>
<%= hidden_field_tag :state, @pre_auth.state, id: nil %>
<%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %>
<%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %>
<%= hidden_field_tag :scope, @pre_auth.scope, id: nil %>
<%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %>
<%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %>
<% if params[:display].present? %>
<%= hidden_field_tag :display, params[:display], id: nil %>
<% end %>
<%= render ButtonComponent.new(
text: t("doorkeeper.authorizations.buttons.authorize"),
variant: :primary,
size: :lg,
full_width: true,
href: oauth_authorization_path,
data: { disable_with: "Authorizing..." }
) %>
<% end %>
<%= form_tag oauth_authorization_path, method: :delete, class: "w-full", data: { turbo: !turbo_disabled } do %>
<%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %>
<%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %>
<%= hidden_field_tag :state, @pre_auth.state, id: nil %>
<%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %>
<%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %>
<%= hidden_field_tag :scope, @pre_auth.scope, id: nil %>
<%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %>
<%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %>
<% if params[:display].present? %>
<%= hidden_field_tag :display, params[:display], id: nil %>
<% end %>
<%= render ButtonComponent.new(
text: t("doorkeeper.authorizations.buttons.deny"),
variant: :outline,
size: :lg,
full_width: true,
href: oauth_authorization_path,
data: { disable_with: "Denying..." }
) %>
<% end %>
</div>
<p class="text-xs text-tertiary text-center">
By authorizing, you allow this app to access your Maybe data according to the permissions above.
</p>
</div>

View file

@ -0,0 +1,17 @@
<div class="bg-container rounded-xl p-6 space-y-6">
<div class="text-center space-y-2">
<div class="mx-auto w-12 h-12 rounded-full bg-success-surface flex items-center justify-center mb-4">
<%= icon("check", class: "w-6 h-6 text-success") %>
</div>
<h1 class="text-2xl font-medium text-primary"><%= t(".title") %></h1>
</div>
<div class="bg-surface-inset rounded-lg p-4">
<p class="text-xs text-secondary mb-2">Authorization Code:</p>
<code id="authorization_code" class="block text-sm font-mono text-primary break-all"><%= params[:code] %></code>
</div>
<p class="text-sm text-secondary text-center">
Copy this code and paste it into the application.
</p>
</div>

View file

@ -0,0 +1,4 @@
<%- submit_btn_css ||= "btn btn-link" %>
<%= form_tag oauth_authorized_application_path(application), method: :delete do %>
<%= submit_tag t("doorkeeper.authorized_applications.buttons.revoke"), onclick: "return confirm('#{ t('doorkeeper.authorized_applications.confirmations.revoke') }')", class: submit_btn_css %>
<% end %>

View file

@ -0,0 +1,24 @@
<header class="page-header">
<h1><%= t("doorkeeper.authorized_applications.index.title") %></h1>
</header>
<main role="main">
<table class="table table-striped">
<thead>
<tr>
<th><%= t("doorkeeper.authorized_applications.index.application") %></th>
<th><%= t("doorkeeper.authorized_applications.index.created_at") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @applications.each do |application| %>
<tr>
<td><%= application.name %></td>
<td><%= application.created_at.strftime(t("doorkeeper.authorized_applications.index.date_format")) %></td>
<td><%= render "delete_form", application: application %></td>
</tr>
<% end %>
</tbody>
</table>
</main>

View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= t("doorkeeper.layouts.admin.title") %></title>
<%= stylesheet_link_tag "doorkeeper/admin/application" %>
<%= csrf_meta_tags %>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-5">
<%= link_to t("doorkeeper.layouts.admin.nav.oauth2_provider"), oauth_applications_path, class: "navbar-brand" %>
<div class="collapse navbar-collapse">
<ul class="navbar-nav mr-auto">
<li class="nav-item <%= "active" if request.path == oauth_applications_path %>">
<%= link_to t("doorkeeper.layouts.admin.nav.applications"), oauth_applications_path, class: "nav-link" %>
</li>
<% if respond_to?(:root_path) %>
<li class="nav-item">
<%= link_to t("doorkeeper.layouts.admin.nav.home"), root_path, class: "nav-link" %>
</li>
<% end %>
</ul>
</div>
</nav>
<div class="doorkeeper-admin container">
<%- if flash[:notice].present? %>
<div class="alert alert-info">
<%= flash[:notice] %>
</div>
<% end -%>
<%= yield %>
</div>
</body>
</html>

View file

@ -0,0 +1,48 @@
<!DOCTYPE html>
<% theme = Current.user&.theme || "system" %>
<html
lang="en"
data-theme="<%= theme %>"
data-controller="theme"
data-theme-user-preference-value="<%= Current.user&.theme || "system" %>"
class="h-full text-primary overflow-hidden lg:overflow-auto font-sans">
<head>
<%= render "layouts/shared/head" %>
</head>
<body class="h-full overflow-hidden lg:overflow-auto antialiased">
<div class="flex flex-col h-full">
<div class="flex flex-col h-full px-6 py-12 bg-surface">
<div class="grow flex flex-col justify-center">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<div class="flex justify-center mt-2 md:mb-6">
<%= image_tag "logo-color.png", class: "w-16 mb-6" %>
</div>
<div class="space-y-2">
<h2 class="text-3xl font-medium text-primary text-center">
Maybe Authorization
</h2>
</div>
</div>
<div class="mt-5 md:mt-8 sm:mx-auto sm:w-full sm:max-w-lg">
<%- if flash[:notice].present? %>
<div class="mb-4 p-3 rounded-lg bg-surface-inset text-sm text-secondary">
<%= flash[:notice] %>
</div>
<% end -%>
<%- if flash[:alert].present? %>
<div class="mb-4 p-3 rounded-lg bg-destructive-surface text-sm text-destructive">
<%= flash[:alert] %>
</div>
<% end -%>
<%= yield %>
</div>
</div>
<%= render "layouts/shared/footer" %>
</div>
</div>
</body>
</html>

View file

@ -6,6 +6,7 @@ nav_sections = [
{ label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
{ label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" },
{ label: t(".security_label"), path: settings_security_path, icon: "shield-check" },
{ label: "API Key", path: settings_api_key_path, icon: "key" },
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
{ label: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? },
{ label: t(".accounts_label"), path: accounts_path, icon: "layers" },

View file

@ -0,0 +1,94 @@
<%= content_for :page_title, "API Key Created" %>
<%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %>
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg">
<div class="flex items-start gap-3">
<%= render FilledIconComponent.new(
icon: "check-circle",
rounded: true,
size: "lg",
variant: :success
) %>
<div class="flex-1">
<h3 class="font-medium text-primary">API Key Created Successfully!</h3>
<p class="text-secondary text-sm mt-1">Your new API key "<%= @api_key.name %>" has been created and is ready to use.</p>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">Your API Key</h4>
<p class="text-secondary text-sm mb-3">Copy and store this key securely. You'll need it to authenticate your API requests.</p>
<div class="bg-container rounded-lg p-3 border border-primary" data-controller="clipboard">
<div class="flex items-center justify-between gap-3">
<code id="api-key-display" class="font-mono text-sm text-primary break-all" data-clipboard-target="source"><%= @api_key.plain_key %></code>
<%= render ButtonComponent.new(
text: "Copy API Key",
variant: "ghost",
icon: "copy",
data: { action: "clipboard#copy" }
) %>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">Key Details</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-secondary">Name:</span>
<span class="text-primary font-medium"><%= @api_key.name %></span>
</div>
<div class="flex justify-between">
<span class="text-secondary">Permissions:</span>
<span class="text-primary">
<%= @api_key.scopes.map { |scope|
case scope
when "read_accounts" then "View Accounts"
when "read_transactions" then "View Transactions"
when "read_balances" then "View Balances"
when "write_transactions" then "Create Transactions"
else scope.humanize
end
}.join(", ") %>
</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">Created:</span>
<span class="text-primary"><%= @api_key.created_at.strftime("%B %d, %Y at %I:%M %p") %></span>
</div>
</div>
</div>
<div class="bg-warning-50 border border-warning-200 rounded-xl p-4">
<div class="flex items-start gap-2">
<%= icon("alert-triangle", class: "w-5 h-5 text-warning-600 mt-0.5") %>
<div>
<h4 class="font-medium text-warning-800 text-sm">Important Security Note</h4>
<p class="text-warning-700 text-sm mt-1">
This is the only time your API key will be displayed. Make sure to copy it now and store it securely.
If you lose this key, you'll need to generate a new one.
</p>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">How to use your API key</h4>
<p class="text-secondary text-sm mb-3">Include your API key in the X-Api-Key header when making requests:</p>
<div class="bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary">
curl -H "X-Api-Key: <%= @api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
</div>
</div>
<div class="flex justify-end pt-4 border-t border-primary">
<%= render LinkComponent.new(
text: "Continue to API Key Settings",
href: settings_api_key_path,
variant: "primary"
) %>
</div>
</div>
<% end %>

View file

@ -0,0 +1,102 @@
<%= turbo_stream.update "main" do %>
<div class="relative max-w-4xl mx-auto flex flex-col w-full h-full">
<div class="grow space-y-4 overflow-y-auto -mx-1 px-1 pb-12">
<h1 class="text-primary text-3xl md:text-xl font-medium">
API Key Created
</h1>
<%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %>
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg">
<div class="flex items-start gap-3">
<%= render FilledIconComponent.new(
icon: "check-circle",
rounded: true,
size: "lg",
variant: :success
) %>
<div class="flex-1">
<h3 class="font-medium text-primary">API Key Created Successfully!</h3>
<p class="text-secondary text-sm mt-1">Your new API key "<%= @api_key.name %>" has been created and is ready to use.</p>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">Your API Key</h4>
<p class="text-secondary text-sm mb-3">Copy and store this key securely. You'll need it to authenticate your API requests.</p>
<div class="bg-container rounded-lg p-3 border border-primary" data-controller="clipboard">
<div class="flex items-center justify-between gap-3">
<code id="api-key-display" class="font-mono text-sm text-primary break-all" data-clipboard-target="source"><%= @api_key.plain_key %></code>
<%= render ButtonComponent.new(
text: "Copy API Key",
variant: "ghost",
icon: "copy",
data: { action: "clipboard#copy" }
) %>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">Key Details</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-secondary">Name:</span>
<span class="text-primary font-medium"><%= @api_key.name %></span>
</div>
<div class="flex justify-between">
<span class="text-secondary">Permissions:</span>
<span class="text-primary">
<%= @api_key.scopes.map { |scope|
case scope
when "read_accounts" then "View Accounts"
when "read_transactions" then "View Transactions"
when "read_balances" then "View Balances"
when "write_transactions" then "Create Transactions"
else scope.humanize
end
}.join(", ") %>
</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">Created:</span>
<span class="text-primary"><%= @api_key.created_at.strftime("%B %d, %Y at %I:%M %p") %></span>
</div>
</div>
</div>
<div class="bg-warning-50 border border-warning-200 rounded-xl p-4">
<div class="flex items-start gap-2">
<%= icon("alert-triangle", class: "w-5 h-5 text-warning-600 mt-0.5") %>
<div>
<h4 class="font-medium text-warning-800 text-sm">Important Security Note</h4>
<p class="text-warning-700 text-sm mt-1">
This is the only time your API key will be displayed. Make sure to copy it now and store it securely.
If you lose this key, you'll need to generate a new one.
</p>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">How to use your API key</h4>
<p class="text-secondary text-sm mb-3">Include your API key in the X-Api-Key header when making requests:</p>
<div class="bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary">
curl -H "X-Api-Key: <%= @api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
</div>
</div>
<div class="flex justify-end pt-4 border-t border-primary">
<%= render LinkComponent.new(
text: "Continue to API Key Settings",
href: settings_api_key_path,
variant: "primary"
) %>
</div>
</div>
<% end %>
</div>
</div>
<% end %>

View file

@ -0,0 +1,60 @@
<%= content_for :page_title, "Create New API Key" %>
<%= settings_section title: "Create New API Key", subtitle: "Generate a new API key to access your Maybe data programmatically." do %>
<%= styled_form_with model: @api_key, url: settings_api_key_path, class: "space-y-4" do |form| %>
<%= form.text_field :name,
placeholder: "e.g., My Budget App, Portfolio Tracker",
label: "API Key Name",
help_text: "Choose a descriptive name to help you identify this key later." %>
<div>
<%= form.label :scopes, "Permissions", class: "block text-sm font-medium text-primary mb-2" %>
<p class="text-sm text-secondary mb-3">Select the permissions this API key should have:</p>
<div class="space-y-2">
<% [
["read", "Read Only", "View your accounts, transactions, and balances"],
["read_write", "Read/Write", "View your data and create new transactions"]
].each do |value, label, description| %>
<div class="bg-surface-inset rounded-lg p-3 border border-primary">
<label class="flex items-start gap-3 cursor-pointer">
<%= radio_button_tag "api_key[scopes]", value, (@api_key&.scopes || []).include?(value),
class: "mt-1" %>
<div class="flex-1">
<div class="font-medium text-primary"><%= label %></div>
<div class="text-sm text-secondary mt-1"><%= description %></div>
</div>
</label>
</div>
<% end %>
</div>
</div>
<div class="bg-warning-50 border border-warning-200 rounded-xl p-4">
<div class="flex items-start gap-2">
<%= icon("alert-triangle", class: "w-5 h-5 text-warning-600 mt-0.5") %>
<div>
<h4 class="font-medium text-warning-800 text-sm">Security Warning</h4>
<p class="text-warning-700 text-sm mt-1">
Your API key will be displayed only once after creation. Make sure to copy and store it securely.
Anyone with access to this key can access your data according to the permissions you select.
</p>
</div>
</div>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-primary">
<%= render LinkComponent.new(
text: "Cancel",
href: settings_api_key_path,
variant: "ghost"
) %>
<%= render ButtonComponent.new(
text: "Create API Key",
variant: "primary",
type: "submit"
) %>
</div>
<% end %>
<% end %>

View file

@ -0,0 +1,192 @@
<%= content_for :page_title, "API Key" %>
<% if @newly_created && @plain_key %>
<%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %>
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg">
<div class="flex items-start gap-3">
<%= render FilledIconComponent.new(
icon: "check-circle",
rounded: true,
size: "lg",
variant: :success
) %>
<div class="flex-1">
<h3 class="font-medium text-primary">API Key Created Successfully!</h3>
<p class="text-secondary text-sm mt-1">Your new API key "<%= @current_api_key.name %>" has been created and is ready to use.</p>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">Your API Key</h4>
<p class="text-secondary text-sm mb-3">Copy and store this key securely. You'll need it to authenticate your API requests.</p>
<div class="bg-container rounded-lg p-3 border border-primary" data-controller="clipboard">
<div class="flex items-center justify-between gap-3">
<code id="api-key-display" class="font-mono text-sm text-primary break-all" data-clipboard-target="source"><%= @current_api_key.plain_key %></code>
<%= render ButtonComponent.new(
text: "Copy API Key",
variant: "ghost",
icon: "copy",
data: { action: "clipboard#copy" }
) %>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">How to use your API key</h4>
<p class="text-secondary text-sm mb-3">Include your API key in the X-Api-Key header when making requests:</p>
<div class="bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary">
curl -H "X-Api-Key: <%= @current_api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
</div>
</div>
<div class="flex justify-end pt-4 border-t border-primary">
<%= render LinkComponent.new(
text: "Continue to API Key Settings",
href: settings_api_key_path,
variant: "primary"
) %>
</div>
</div>
<% end %>
<% elsif @current_api_key %>
<%= settings_section title: "Your API Key", subtitle: "Manage your API key for programmatic access to your Maybe data." do %>
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg flex justify-between items-center">
<div class="flex items-center gap-3">
<%= render FilledIconComponent.new(
icon: "key",
rounded: true,
size: "lg"
) %>
<div class="text-sm space-y-1">
<p class="text-primary font-medium"><%= @current_api_key.name %></p>
<p class="text-secondary">
Created <%= time_ago_in_words(@current_api_key.created_at) %> ago
<% if @current_api_key.last_used_at %>
• Last used <%= time_ago_in_words(@current_api_key.last_used_at) %> ago
<% else %>
• Never used
<% end %>
</p>
</div>
</div>
<div class="rounded-md bg-success px-2 py-1">
<p class="text-success-foreground font-medium text-xs">Active</p>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">Permissions</h4>
<div class="flex flex-wrap gap-2">
<% @current_api_key.scopes.each do |scope| %>
<span class="inline-flex items-center gap-1 px-2 py-1 bg-primary text-primary-foreground rounded-full text-xs font-medium">
<%= icon("shield-check", class: "w-3 h-3") %>
<%= case scope
when "read" then "Read Only"
when "read_write" then "Read/Write"
else scope.humanize
end %>
</span>
<% end %>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">Your API Key</h4>
<p class="text-secondary text-sm mb-3">Copy and store this key securely. You'll need it to authenticate your API requests.</p>
<div class="bg-container rounded-lg p-3 border border-primary" data-controller="clipboard">
<div class="flex items-center justify-between gap-3">
<code id="api-key-display" class="font-mono text-sm text-primary break-all" data-clipboard-target="source"><%= @current_api_key.plain_key %></code>
<%= render ButtonComponent.new(
text: "Copy API Key",
variant: "ghost",
icon: "copy",
data: { action: "clipboard#copy" }
) %>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">How to use your API key</h4>
<p class="text-secondary text-sm mb-3">Include your API key in the X-Api-Key header when making requests:</p>
<div class="bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary">
curl -H "X-Api-Key: <%= @current_api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
</div>
</div>
<div class="flex flex-col sm:flex-row gap-3 pt-4 border-t border-primary">
<%= render LinkComponent.new(
text: "Create New Key",
href: new_settings_api_key_path(regenerate: true),
variant: "secondary"
) %>
<%= render ButtonComponent.new(
text: "Revoke Key",
href: settings_api_key_path,
method: :delete,
variant: "destructive",
data: {
turbo_confirm: "Are you sure you want to revoke this API key?"
}
) %>
</div>
</div>
<% end %>
<% else %>
<%= settings_section title: "Create Your API Key", subtitle: "Get programmatic access to your Maybe data" do %>
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg">
<div class="flex items-start gap-3">
<%= render FilledIconComponent.new(
icon: "key",
rounded: true,
size: "lg"
) %>
<div class="flex-1">
<h3 class="font-medium text-primary">Access your account data programmatically</h3>
<p class="text-secondary text-sm mt-1">Generate an API key to integrate with your applications and access your financial data securely.</p>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">What you can do with API keys:</h4>
<ul class="space-y-2 text-sm text-secondary">
<li class="flex items-start gap-2">
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
<span>Access your accounts and balances</span>
</li>
<li class="flex items-start gap-2">
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
<span>View transaction history</span>
</li>
<li class="flex items-start gap-2">
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
<span>Create new transactions</span>
</li>
<li class="flex items-start gap-2">
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
<span>Integrate with third-party applications</span>
</li>
</ul>
</div>
<div class="flex justify-start">
<%= render LinkComponent.new(
text: "Create API Key",
href: new_settings_api_key_path,
variant: "primary"
) %>
</div>
</div>
<% end %>
<% end %>

View file

@ -1,14 +1,11 @@
<%# locals: (entry:) %>
<%# locals: (model:, account:) %>
<% type = params[:type] || "buy" %>
<%= styled_form_with model: entry, url: trades_path, data: { controller: "trade-form" } do |form| %>
<%= form.hidden_field :account_id %>
<%= styled_form_with url: trades_path(account_id: account&.id), scope: :model, data: { controller: "trade-form" } do |form| %>
<div class="space-y-4">
<% if entry.errors.any? %>
<%= render "shared/form_errors", model: entry %>
<% if model.errors.any? %>
<%= render "shared/form_errors", model: model %>
<% end %>
<div class="space-y-2">
@ -22,7 +19,7 @@
{ label: t(".type"), selected: type },
{ data: {
action: "trade-form#changeType",
trade_form_url_param: new_trade_path(account_id: entry.account&.id || entry.account_id),
trade_form_url_param: new_trade_path(account_id: account&.id),
trade_form_key_param: "type",
}} %>
@ -41,10 +38,10 @@
<% end %>
<% end %>
<%= form.date_field :date, label: true, value: Date.current, required: true %>
<%= form.date_field :date, label: true, value: model.date || Date.current, required: true %>
<% unless %w[buy sell].include?(type) %>
<%= form.money_field :amount, label: t(".amount"), required: true %>
<%= form.money_field :amount, label: t(".amount"), value: model.amount, required: true %>
<% end %>
<% if %w[deposit withdrawal].include?(type) %>

View file

@ -1,6 +1,6 @@
<%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_body do %>
<%= render "trades/form", entry: @entry %>
<%= render "trades/form", model: @model, account: @account %>
<% end %>
<% end %>

View file

@ -2,7 +2,7 @@
<div class="grid grid-cols-1 md:grid-cols-3 bg-container rounded-xl shadow-border-xs md:divide-x divide-y md:divide-y-0 divide-alpha-black-100 theme-dark:divide-alpha-white-200">
<div class="p-4 space-y-2">
<p class="text-sm text-secondary">Total transactions</p>
<p class="text-primary font-medium text-xl" id="total-transactions"><%= totals.transactions_count.round(0) %></p>
<p class="text-primary font-medium text-xl" id="total-transactions"><%= totals.count.round(0) %></p>
</div>
<div class="p-4 space-y-2">
<p class="text-sm text-secondary">Income</p>

View file

@ -6,11 +6,12 @@
<%= turbo_frame_tag dom_id(transaction) do %>
<div class="grid grid-cols-12 items-center text-primary text-sm font-medium p-4 lg:p-4
<%= @focused_record == entry || @focused_record == transaction ?
"border border-gray-900 rounded-lg" : "" %>">
"border border-gray-900 rounded-lg" : "" %>
<%= entry.excluded ? "opacity-50 text-gray-400" : "" %>">
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 lg:col-span-6">
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 <%= view_ctx == "global" ? "lg:col-span-8" : "lg:col-span-6" %>">
<%= check_box_tag dom_id(entry, "selection"),
disabled: transaction.transfer?,
disabled: transaction.transfer.present?,
class: "checkbox checkbox--light",
data: {
id: entry.id,
@ -35,33 +36,49 @@
<div class="truncate">
<div class="space-y-0.5">
<div class="flex items-center gap-1">
<%= link_to(
transaction.transfer? ? transaction.transfer.name : entry.name,
transaction.transfer? ? transfer_path(transaction.transfer) : entry_path(entry),
data: {
turbo_frame: "drawer",
turbo_prefetch: false
},
class: "hover:underline"
) %>
<div class="flex items-center gap-1 min-w-0">
<div class="truncate flex-shrink">
<% if transaction.transfer? %>
<%= link_to(
entry.name,
transaction.transfer.present? ? transfer_path(transaction.transfer) : entry_path(entry),
data: {
turbo_frame: "drawer",
turbo_prefetch: false
},
class: "hover:underline"
) %>
<% else %>
<%= link_to(
entry.name,
entry_path(entry),
data: {
turbo_frame: "drawer",
turbo_prefetch: false
},
class: "hover:underline"
) %>
<% end %>
</div>
<% if entry.excluded %>
<span class="text-orange-500" title="One-time <%= entry.amount.negative? ? "income" : "expense" %> (excluded from averages)">
<%= icon "asterisk", size: "sm", color: "current" %>
</span>
<% end %>
<div class="flex items-center gap-1 flex-shrink-0">
<% if transaction.one_time? %>
<span class="text-orange-500" title="One-time <%= entry.amount.negative? ? "income" : "expense" %> (excluded from averages)">
<%= icon "asterisk", size: "sm", color: "current" %>
</span>
<% end %>
<% if transaction.transfer? %>
<%= render "transactions/transfer_match", transaction: transaction %>
<% end %>
<% if transaction.transfer.present? %>
<%= render "transactions/transfer_match", transaction: transaction %>
<% end %>
</div>
</div>
<div class="text-secondary text-xs font-normal hidden lg:block">
<% if transaction.transfer? %>
<%= render "transfers/account_links",
transfer: transaction.transfer,
is_inflow: transaction.transfer_as_inflow.present? %>
<span class="text-secondary">
<%= transaction.loan_payment? ? "Loan Payment" : "Transfer" %> • <%= entry.account.name %>
</span>
<% else %>
<%= link_to entry.account.name,
account_path(entry.account, tab: "transactions", focused_record_id: entry.id),
@ -85,14 +102,16 @@
class: ["text-green-600": entry.amount.negative?] %>
</div>
<div class="col-span-2 justify-self-end hidden lg:block">
<% if balance_trend&.trend %>
<%= tag.p format_money(balance_trend.trend.current),
<% if view_ctx != "global" %>
<div class="col-span-2 justify-self-end hidden lg:block">
<% if balance_trend&.trend %>
<%= tag.p format_money(balance_trend.trend.current),
class: "font-medium text-sm text-primary" %>
<% else %>
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
<% end %>
</div>
<% else %>
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
<% end %>
</div>
<% end %>
</div>
<% end %>
<% end %>

View file

@ -43,7 +43,7 @@
</div>
</header>
<%= render "summary", totals: @totals %>
<%= render "summary", totals: @search.totals %>
<div id="transactions"
data-controller="bulk-select"

View file

@ -106,13 +106,34 @@
data: { controller: "auto-submit-form" } do |f| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-primary">One-time <%= @entry.amount.negative? ? "Income" : "Expense" %></h4>
<p class="text-secondary">One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.</p>
<h4 class="text-primary">Exclude</h4>
<p class="text-secondary">Excluded transactions will be removed from budgeting calculations and reports.</p>
</div>
<%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
</div>
<% end %>
</div>
<div class="pb-4">
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.fields_for :entryable do |ef| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-primary">One-time <%= @entry.amount.negative? ? "Income" : "Expense" %></h4>
<p class="text-secondary">One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.</p>
</div>
<%= ef.toggle :kind, {
checked: @entry.transaction.one_time?,
data: { auto_submit_form_target: "auto" }
}, "one_time", "standard" %>
</div>
<% end %>
<% end %>
<div class="flex items-center justify-between gap-4 p-3">
<div class="text-sm space-y-1">

View file

@ -38,5 +38,8 @@ module Maybe
config.lookbook.preview_display_options = {
theme: [ "light", "dark" ] # available in view as params[:theme]
}
# Enable Rack::Attack middleware for API rate limiting
config.middleware.use Rack::Attack
end
end

View file

@ -80,6 +80,29 @@
22
],
"note": ""
},
{
"warning_type": "Mass Assignment",
"warning_code": 105,
"fingerprint": "85e2c11853dd6c69b1953a6ec3ad661cd0ce3df55e4e5beff92365b6ed601171",
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/api/v1/transactions_controller.rb",
"line": 255,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.require(:transaction).permit(:account_id, :date, :amount, :name, :description, :notes, :currency, :category_id, :merchant_id, :nature, :tag_ids => ([]))",
"render_path": null,
"location": {
"type": "method",
"class": "Api::V1::TransactionsController",
"method": "transaction_params"
},
"user_input": ":account_id",
"confidence": "High",
"cwe_id": [
915
],
"note": "account_id is properly validated in create action - line 79 ensures account belongs to user's family: family.accounts.find(transaction_params[:account_id])"
}
],
"brakeman_version": "7.0.2"

View file

@ -0,0 +1,25 @@
# Auto-generate Active Record encryption keys for self-hosted instances
# This ensures encryption works out of the box without manual setup
if Rails.application.config.app_mode.self_hosted? && !Rails.application.credentials.active_record_encryption.present?
# Check if keys are provided via environment variables
primary_key = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"]
deterministic_key = ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"]
key_derivation_salt = ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"]
# If any key is missing, generate all of them based on SECRET_KEY_BASE
if primary_key.blank? || deterministic_key.blank? || key_derivation_salt.blank?
# Use SECRET_KEY_BASE as the seed for deterministic key generation
# This ensures keys are consistent across container restarts
secret_base = Rails.application.secret_key_base
# Generate deterministic keys from the secret base
primary_key = Digest::SHA256.hexdigest("#{secret_base}:primary_key")[0..63]
deterministic_key = Digest::SHA256.hexdigest("#{secret_base}:deterministic_key")[0..63]
key_derivation_salt = Digest::SHA256.hexdigest("#{secret_base}:key_derivation_salt")[0..63]
end
# Configure Active Record encryption
Rails.application.config.active_record.encryption.primary_key = primary_key
Rails.application.config.active_record.encryption.deterministic_key = deterministic_key
Rails.application.config.active_record.encryption.key_derivation_salt = key_derivation_salt
end

View file

@ -0,0 +1,546 @@
# frozen_string_literal: true
Doorkeeper.configure do
# Change the ORM that doorkeeper will use (requires ORM extensions installed).
# Check the list of supported ORMs here: https://github.com/doorkeeper-gem/doorkeeper#orms
orm :active_record
# This block will be called to check whether the resource owner is authenticated or not.
resource_owner_authenticator do
# Manually replicate the app's session-based authentication logic, since
# Doorkeeper controllers don't include our Authentication concern.
if (session_id = cookies.signed[:session_token]).present?
if (session_record = Session.find_by(id: session_id))
# Set Current.session so downstream code expecting it behaves normally.
Current.session = session_record
# Return the authenticated user object as the resource owner.
session_record.user
else
redirect_to new_session_url
end
else
redirect_to new_session_url
end
end
# If you didn't skip applications controller from Doorkeeper routes in your application routes.rb
# file then you need to declare this block in order to restrict access to the web interface for
# adding oauth authorized applications. In other case it will return 403 Forbidden response
# every time somebody will try to access the admin web interface.
#
admin_authenticator do
if (session_id = cookies.signed[:session_token]).present?
if (session_record = Session.find_by(id: session_id))
Current.session = session_record
head :forbidden unless session_record.user&.super_admin?
else
redirect_to new_session_url
end
else
redirect_to new_session_url
end
end
# You can use your own model classes if you need to extend (or even override) default
# Doorkeeper models such as `Application`, `AccessToken` and `AccessGrant.
#
# By default Doorkeeper ActiveRecord ORM uses its own classes:
#
# access_token_class "Doorkeeper::AccessToken"
# access_grant_class "Doorkeeper::AccessGrant"
# application_class "Doorkeeper::Application"
#
# Don't forget to include Doorkeeper ORM mixins into your custom models:
#
# * ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken - for access token
# * ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessGrant - for access grant
# * ::Doorkeeper::Orm::ActiveRecord::Mixins::Application - for application (OAuth2 clients)
#
# For example:
#
# access_token_class "MyAccessToken"
#
# class MyAccessToken < ApplicationRecord
# include ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken
#
# self.table_name = "hey_i_wanna_my_name"
#
# def destroy_me!
# destroy
# end
# end
# Enables polymorphic Resource Owner association for Access Tokens and Access Grants.
# By default this option is disabled.
#
# Make sure you properly setup you database and have all the required columns (run
# `bundle exec rails generate doorkeeper:enable_polymorphic_resource_owner` and execute Rails
# migrations).
#
# If this option enabled, Doorkeeper will store not only Resource Owner primary key
# value, but also it's type (class name). See "Polymorphic Associations" section of
# Rails guides: https://guides.rubyonrails.org/association_basics.html#polymorphic-associations
#
# [NOTE] If you apply this option on already existing project don't forget to manually
# update `resource_owner_type` column in the database and fix migration template as it will
# set NOT NULL constraint for Access Grants table.
#
# use_polymorphic_resource_owner
# If you are planning to use Doorkeeper in Rails 5 API-only application, then you might
# want to use API mode that will skip all the views management and change the way how
# Doorkeeper responds to a requests.
#
# api_only
# Enforce token request content type to application/x-www-form-urlencoded.
# It is not enabled by default to not break prior versions of the gem.
#
# enforce_content_type
# Authorization Code expiration time (default: 10 minutes).
#
# authorization_code_expires_in 10.minutes
# Access token expiration time (default: 2 hours).
# If you set this to `nil` Doorkeeper will not expire the token and omit expires_in in response.
# It is RECOMMENDED to set expiration time explicitly.
# Prefer access_token_expires_in 100.years or similar,
# which would be functionally equivalent and avoid the risk of unexpected behavior by callers.
#
access_token_expires_in 1.year
# Assign custom TTL for access tokens. Will be used instead of access_token_expires_in
# option if defined. In case the block returns `nil` value Doorkeeper fallbacks to
# +access_token_expires_in+ configuration option value. If you really need to issue a
# non-expiring access token (which is not recommended) then you need to return
# Float::INFINITY from this block.
#
# `context` has the following properties available:
#
# * `client` - the OAuth client application (see Doorkeeper::OAuth::Client)
# * `grant_type` - the grant type of the request (see Doorkeeper::OAuth)
# * `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes)
# * `resource_owner` - authorized resource owner instance (if present)
#
# custom_access_token_expires_in do |context|
# context.client.additional_settings.implicit_oauth_expiration
# end
# Use a custom class for generating the access token.
# See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-access-token-generator
#
# access_token_generator '::Doorkeeper::JWT'
# The controller +Doorkeeper::ApplicationController+ inherits from.
# Defaults to +ActionController::Base+ unless +api_only+ is set, which changes the default to
# +ActionController::API+. The return value of this option must be a stringified class name.
# See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-controllers
#
# base_controller 'ApplicationController'
# Reuse access token for the same resource owner within an application (disabled by default).
#
# This option protects your application from creating new tokens before old **valid** one becomes
# expired so your database doesn't bloat. Keep in mind that when this option is enabled Doorkeeper
# doesn't update existing token expiration time, it will create a new token instead if no active matching
# token found for the application, resources owner and/or set of scopes.
# Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383
#
# You can not enable this option together with +hash_token_secrets+.
#
# reuse_access_token
# In case you enabled `reuse_access_token` option Doorkeeper will try to find matching
# token using `matching_token_for` Access Token API that searches for valid records
# in batches in order not to pollute the memory with all the database records. By default
# Doorkeeper uses batch size of 10 000 records. You can increase or decrease this value
# depending on your needs and server capabilities.
#
# token_lookup_batch_size 10_000
# Set a limit for token_reuse if using reuse_access_token option
#
# This option limits token_reusability to some extent.
# If not set then access_token will be reused unless it expires.
# Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/1189
#
# This option should be a percentage(i.e. (0,100])
#
# token_reuse_limit 100
# Only allow one valid access token obtained via client credentials
# per client. If a new access token is obtained before the old one
# expired, the old one gets revoked (disabled by default)
#
# When enabling this option, make sure that you do not expect multiple processes
# using the same credentials at the same time (e.g. web servers spanning
# multiple machines and/or processes).
#
# revoke_previous_client_credentials_token
# Only allow one valid access token obtained via authorization code
# per client. If a new access token is obtained before the old one
# expired, the old one gets revoked (disabled by default)
#
# revoke_previous_authorization_code_token
# Require non-confidential clients to use PKCE when using an authorization code
# to obtain an access_token (disabled by default)
#
force_pkce
# Hash access and refresh tokens before persisting them.
# This will disable the possibility to use +reuse_access_token+
# since plain values can no longer be retrieved.
#
# Note: If you are already a user of doorkeeper and have existing tokens
# in your installation, they will be invalid without adding 'fallback: :plain'.
#
# For test environment, allow fallback to plain tokens to make testing easier
if Rails.env.test?
hash_token_secrets fallback: :plain
else
hash_token_secrets
end
# By default, token secrets will be hashed using the
# +Doorkeeper::Hashing::SHA256+ strategy.
#
# If you wish to use another hashing implementation, you can override
# this strategy as follows:
#
# hash_token_secrets using: '::Doorkeeper::Hashing::MyCustomHashImpl'
#
# Keep in mind that changing the hashing function will invalidate all existing
# secrets, if there are any.
# Hash application secrets before persisting them.
#
hash_application_secrets
#
# By default, applications will be hashed
# with the +Doorkeeper::SecretStoring::SHA256+ strategy.
#
# If you wish to use bcrypt for application secret hashing, uncomment
# this line instead:
#
# hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt'
# When the above option is enabled, and a hashed token or secret is not found,
# you can allow to fall back to another strategy. For users upgrading
# doorkeeper and wishing to enable hashing, you will probably want to enable
# the fallback to plain tokens.
#
# This will ensure that old access tokens and secrets
# will remain valid even if the hashing above is enabled.
#
# This can be done by adding 'fallback: plain', e.g. :
#
# hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt', fallback: :plain
# Issue access tokens with refresh token (disabled by default), you may also
# pass a block which accepts `context` to customize when to give a refresh
# token or not. Similar to +custom_access_token_expires_in+, `context` has
# the following properties:
#
# `client` - the OAuth client application (see Doorkeeper::OAuth::Client)
# `grant_type` - the grant type of the request (see Doorkeeper::OAuth)
# `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes)
#
use_refresh_token
# Provide support for an owner to be assigned to each registered application (disabled by default)
# Optional parameter confirmation: true (default: false) if you want to enforce ownership of
# a registered application
# NOTE: you must also run the rails g doorkeeper:application_owner generator
# to provide the necessary support
#
enable_application_owner confirmation: false
# Define access token scopes for your provider
# For more information go to
# https://doorkeeper.gitbook.io/guides/ruby-on-rails/scopes
#
default_scopes :read
optional_scopes :read_write
# Allows to restrict only certain scopes for grant_type.
# By default, all the scopes will be available for all the grant types.
#
# Keys to this hash should be the name of grant_type and
# values should be the array of scopes for that grant type.
# Note: scopes should be from configured_scopes (i.e. default or optional)
#
# scopes_by_grant_type password: [:write], client_credentials: [:update]
# Forbids creating/updating applications with arbitrary scopes that are
# not in configuration, i.e. +default_scopes+ or +optional_scopes+.
# (disabled by default)
#
# enforce_configured_scopes
# Change the way client credentials are retrieved from the request object.
# By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
# falls back to the `:client_id` and `:client_secret` params from the `params` object.
# Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated
# for more information on customization
#
# client_credentials :from_basic, :from_params
# Change the way access token is authenticated from the request object.
# By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
# falls back to the `:access_token` or `:bearer_token` params from the `params` object.
# Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated
# for more information on customization
#
# access_token_methods :from_bearer_authorization, :from_access_token_param, :from_bearer_param
# Forces the usage of the HTTPS protocol in non-native redirect uris (enabled
# by default in non-development environments). OAuth2 delegates security in
# communication to the HTTPS protocol so it is wise to keep this enabled.
#
# Callable objects such as proc, lambda, block or any object that responds to
# #call can be used in order to allow conditional checks (to allow non-SSL
# redirects to localhost for example).
#
# Allow custom URL schemes for mobile apps
force_ssl_in_redirect_uri false
# Specify what redirect URI's you want to block during Application creation.
# Any redirect URI is allowed by default.
#
# You can use this option in order to forbid URI's with 'javascript' scheme
# for example.
#
# Block javascript URIs but allow custom schemes
forbid_redirect_uri { |uri| uri.scheme.to_s.downcase == "javascript" }
# Allows to set blank redirect URIs for Applications in case Doorkeeper configured
# to use URI-less OAuth grant flows like Client Credentials or Resource Owner
# Password Credentials. The option is on by default and checks configured grant
# types, but you **need** to manually drop `NOT NULL` constraint from `redirect_uri`
# column for `oauth_applications` database table.
#
# You can completely disable this feature with:
#
# allow_blank_redirect_uri false
#
# Or you can define your custom check:
#
# allow_blank_redirect_uri do |grant_flows, client|
# client.superapp?
# end
# Specify how authorization errors should be handled.
# By default, doorkeeper renders json errors when access token
# is invalid, expired, revoked or has invalid scopes.
#
# If you want to render error response yourself (i.e. rescue exceptions),
# set +handle_auth_errors+ to `:raise` and rescue Doorkeeper::Errors::InvalidToken
# or following specific errors:
#
# Doorkeeper::Errors::TokenForbidden, Doorkeeper::Errors::TokenExpired,
# Doorkeeper::Errors::TokenRevoked, Doorkeeper::Errors::TokenUnknown
#
# handle_auth_errors :raise
#
# If you want to redirect back to the client application in accordance with
# https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, you can set
# +handle_auth_errors+ to :redirect
#
# handle_auth_errors :redirect
# Customize token introspection response.
# Allows to add your own fields to default one that are required by the OAuth spec
# for the introspection response. It could be `sub`, `aud` and so on.
# This configuration option can be a proc, lambda or any Ruby object responds
# to `.call` method and result of it's invocation must be a Hash.
#
# custom_introspection_response do |token, context|
# {
# "sub": "Z5O3upPC88QrAjx00dis",
# "aud": "https://protected.example.net/resource",
# "username": User.find(token.resource_owner_id).username
# }
# end
#
# or
#
# custom_introspection_response CustomIntrospectionResponder
# Specify what grant flows are enabled in array of Strings. The valid
# strings and the flows they enable are:
#
# "authorization_code" => Authorization Code Grant Flow
# "implicit" => Implicit Grant Flow
# "password" => Resource Owner Password Credentials Grant Flow
# "client_credentials" => Client Credentials Grant Flow
#
# If not specified, Doorkeeper enables authorization_code and
# client_credentials.
#
# implicit and password grant flows have risks that you should understand
# before enabling:
# https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.2
# https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.3
#
# grant_flows %w[authorization_code client_credentials]
# Allows to customize OAuth grant flows that +each+ application support.
# You can configure a custom block (or use a class respond to `#call`) that must
# return `true` in case Application instance supports requested OAuth grant flow
# during the authorization request to the server. This configuration +doesn't+
# set flows per application, it only allows to check if application supports
# specific grant flow.
#
# For example you can add an additional database column to `oauth_applications` table,
# say `t.array :grant_flows, default: []`, and store allowed grant flows that can
# be used with this application there. Then when authorization requested Doorkeeper
# will call this block to check if specific Application (passed with client_id and/or
# client_secret) is allowed to perform the request for the specific grant type
# (authorization, password, client_credentials, etc).
#
# Example of the block:
#
# ->(flow, client) { client.grant_flows.include?(flow) }
#
# In case this option invocation result is `false`, Doorkeeper server returns
# :unauthorized_client error and stops the request.
#
# @param allow_grant_flow_for_client [Proc] Block or any object respond to #call
# @return [Boolean] `true` if allow or `false` if forbid the request
#
# allow_grant_flow_for_client do |grant_flow, client|
# # `grant_flows` is an Array column with grant
# # flows that application supports
#
# client.grant_flows.include?(grant_flow)
# end
# If you need arbitrary Resource Owner-Client authorization you can enable this option
# and implement the check your need. Config option must respond to #call and return
# true in case resource owner authorized for the specific application or false in other
# cases.
#
# By default all Resource Owners are authorized to any Client (application).
#
# authorize_resource_owner_for_client do |client, resource_owner|
# resource_owner.admin? || client.owners_allowlist.include?(resource_owner)
# end
# Allows additional data fields to be sent while granting access to an application,
# and for this additional data to be included in subsequently generated access tokens.
# The 'authorizations/new' page will need to be overridden to include this additional data
# in the request params when granting access. The access grant and access token models
# will both need to respond to these additional data fields, and have a database column
# to store them in.
#
# Example:
# You have a multi-tenanted platform and want to be able to grant access to a specific
# tenant, rather than all the tenants a user has access to. You can use this config
# option to specify that a ':tenant_id' will be passed when authorizing. This tenant_id
# will be included in the access tokens. When a request is made with one of these access
# tokens, you can check that the requested data belongs to the specified tenant.
#
# Default value is an empty Array: []
# custom_access_token_attributes [:tenant_id]
# Hook into the strategies' request & response life-cycle in case your
# application needs advanced customization or logging:
#
# before_successful_strategy_response do |request|
# puts "BEFORE HOOK FIRED! #{request}"
# end
#
# after_successful_strategy_response do |request, response|
# puts "AFTER HOOK FIRED! #{request}, #{response}"
# end
# Hook into Authorization flow in order to implement Single Sign Out
# or add any other functionality. Inside the block you have an access
# to `controller` (authorizations controller instance) and `context`
# (Doorkeeper::OAuth::Hooks::Context instance) which provides pre auth
# or auth objects with issued token based on hook type (before or after).
#
# before_successful_authorization do |controller, context|
# Rails.logger.info(controller.request.params.inspect)
#
# Rails.logger.info(context.pre_auth.inspect)
# end
#
# after_successful_authorization do |controller, context|
# controller.session[:logout_urls] <<
# Doorkeeper::Application
# .find_by(controller.request.params.slice(:redirect_uri))
# .logout_uri
#
# Rails.logger.info(context.auth.inspect)
# Rails.logger.info(context.issued_token)
# end
# Under some circumstances you might want to have applications auto-approved,
# so that the user skips the authorization step.
# For example if dealing with a trusted application.
#
# skip_authorization do |resource_owner, client|
# client.superapp? or resource_owner.admin?
# end
# Configure custom constraints for the Token Introspection request.
# By default this configuration option allows to introspect a token by another
# token of the same application, OR to introspect the token that belongs to
# authorized client (from authenticated client) OR when token doesn't
# belong to any client (public token). Otherwise requester has no access to the
# introspection and it will return response as stated in the RFC.
#
# Block arguments:
#
# @param token [Doorkeeper::AccessToken]
# token to be introspected
#
# @param authorized_client [Doorkeeper::Application]
# authorized client (if request is authorized using Basic auth with
# Client Credentials for example)
#
# @param authorized_token [Doorkeeper::AccessToken]
# Bearer token used to authorize the request
#
# In case the block returns `nil` or `false` introspection responses with 401 status code
# when using authorized token to introspect, or you'll get 200 with { "active": false } body
# when using authorized client to introspect as stated in the
# RFC 7662 section 2.2. Introspection Response.
#
# Using with caution:
# Keep in mind that these three parameters pass to block can be nil as following case:
# `authorized_client` is nil if and only if `authorized_token` is present, and vice versa.
# `token` will be nil if and only if `authorized_token` is present.
# So remember to use `&` or check if it is present before calling method on
# them to make sure you doesn't get NoMethodError exception.
#
# You can define your custom check:
#
# allow_token_introspection do |token, authorized_client, authorized_token|
# if authorized_token
# # customize: require `introspection` scope
# authorized_token.application == token&.application ||
# authorized_token.scopes.include?("introspection")
# elsif token.application
# # `protected_resource` is a new database boolean column, for example
# authorized_client == token.application || authorized_client.protected_resource?
# else
# # public token (when token.application is nil, token doesn't belong to any application)
# true
# end
# end
#
# Or you can completely disable any token introspection:
#
# allow_token_introspection false
#
# If you need to block the request at all, then configure your routes.rb or web-server
# like nginx to forbid the request.
# WWW-Authenticate Realm (default: "Doorkeeper").
#
# realm "Doorkeeper"
end

View file

@ -0,0 +1,20 @@
# Disable CSRF protection for Doorkeeper endpoints.
#
# OAuth requests (both the authorization endpoint hit by users and the token
# endpoint hit by confidential/public clients) are performed by third-party
# clients that do not have access to the Rails session, and therefore cannot
# include the standard CSRF token. Requiring the token in these controllers
# breaks the OAuth flow with an ActionController::InvalidAuthenticityToken
# error. It is safe to disable CSRF verification here because Doorkeeper's
# endpoints already implement their own security semantics defined by the
# OAuth 2.0 specification (PKCE, client/secret checks, etc.).
#
# This hook runs on each application reload in development and ensures the
# callback is applied after Doorkeeper loads its controllers.
Rails.application.config.to_prepare do
# Doorkeeper::ApplicationController is the base controller for all
# Doorkeeper-provided controllers (AuthorizationsController, TokensController,
# TokenInfoController, etc.). Removing the authenticity-token filter here
# cascades to all of them.
Doorkeeper::ApplicationController.skip_forgery_protection
end

View file

@ -0,0 +1,6 @@
# Ensure Doorkeeper controllers use the correct layout
Rails.application.config.to_prepare do
Doorkeeper::AuthorizationsController.layout "doorkeeper/application"
Doorkeeper::AuthorizedApplicationsController.layout "doorkeeper/application"
Doorkeeper::ApplicationsController.layout "doorkeeper/application"
end

View file

@ -0,0 +1,66 @@
# frozen_string_literal: true
class Rack::Attack
# Enable Rack::Attack
enabled = Rails.env.production? || Rails.env.staging?
# Throttle requests to the OAuth token endpoint
throttle("oauth/token", limit: 10, period: 1.minute) do |request|
request.ip if request.path == "/oauth/token"
end
# Throttle API requests per access token
throttle("api/requests", limit: 100, period: 1.hour) do |request|
if request.path.start_with?("/api/")
# Extract access token from Authorization header
auth_header = request.get_header("HTTP_AUTHORIZATION")
if auth_header&.start_with?("Bearer ")
token = auth_header.split(" ").last
"api_token:#{Digest::SHA256.hexdigest(token)}"
else
# Fall back to IP-based limiting for unauthenticated requests
"api_ip:#{request.ip}"
end
end
end
# More permissive throttling for API requests by IP (for development/testing)
throttle("api/ip", limit: 200, period: 1.hour) do |request|
request.ip if request.path.start_with?("/api/")
end
# Block requests that appear to be malicious
blocklist("block malicious requests") do |request|
# Block requests with suspicious user agents
suspicious_user_agents = [
/sqlmap/i,
/nmap/i,
/nikto/i,
/masscan/i
]
user_agent = request.user_agent
suspicious_user_agents.any? { |pattern| user_agent =~ pattern } if user_agent
end
# Configure response for throttled requests
self.throttled_responder = lambda do |request|
[
429, # status
{
"Content-Type" => "application/json",
"Retry-After" => "60"
},
[ { error: "Rate limit exceeded. Try again later." }.to_json ]
]
end
# Configure response for blocked requests
self.blocklisted_responder = lambda do |request|
[
403, # status
{ "Content-Type" => "application/json" },
[ { error: "Request blocked." }.to_json ]
]
end
end

View file

@ -0,0 +1,155 @@
en:
activerecord:
attributes:
doorkeeper/application:
name: 'Name'
redirect_uri: 'Redirect URI'
errors:
models:
doorkeeper/application:
attributes:
redirect_uri:
fragment_present: 'cannot contain a fragment.'
invalid_uri: 'must be a valid URI.'
unspecified_scheme: 'must specify a scheme.'
relative_uri: 'must be an absolute URI.'
secured_uri: 'must be an HTTPS/SSL URI.'
forbidden_uri: 'is forbidden by the server.'
scopes:
not_match_configured: "doesn't match configured on the server."
doorkeeper:
applications:
confirmations:
destroy: 'Are you sure?'
buttons:
edit: 'Edit'
destroy: 'Destroy'
submit: 'Submit'
cancel: 'Cancel'
authorize: 'Authorize'
form:
error: 'Whoops! Check your form for possible errors'
help:
confidential: 'Application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.'
redirect_uri: 'Use one line per URI'
blank_redirect_uri: "Leave it blank if you configured your provider to use Client Credentials, Resource Owner Password Credentials or any other grant type that doesn't require redirect URI."
scopes: 'Separate scopes with spaces. Leave blank to use the default scopes.'
edit:
title: 'Edit application'
index:
title: 'Your applications'
new: 'New Application'
name: 'Name'
callback_url: 'Callback URL'
confidential: 'Confidential?'
actions: 'Actions'
confidentiality:
'yes': 'Yes'
'no': 'No'
new:
title: 'New Application'
show:
title: 'Application: %{name}'
application_id: 'UID'
secret: 'Secret'
secret_hashed: 'Secret hashed'
scopes: 'Scopes'
confidential: 'Confidential'
callback_urls: 'Callback urls'
actions: 'Actions'
not_defined: 'Not defined'
authorizations:
buttons:
authorize: 'Authorize'
deny: 'Deny'
error:
title: 'An error has occurred'
new:
title: 'Authorization required'
prompt: 'Authorize %{client_name} to use your account?'
able_to: 'This application will be able to'
show:
title: 'Authorization code'
form_post:
title: 'Submit this form'
authorized_applications:
confirmations:
revoke: 'Are you sure?'
buttons:
revoke: 'Revoke'
index:
title: 'Your authorized applications'
application: 'Application'
created_at: 'Created At'
date_format: '%Y-%m-%d %H:%M:%S'
pre_authorization:
status: 'Pre-authorization'
errors:
messages:
# Common error messages
invalid_request:
unknown: 'The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.'
missing_param: 'Missing required parameter: %{value}.'
request_not_authorized: 'Request need to be authorized. Required parameter for authorizing request is missing or invalid.'
invalid_code_challenge: 'Code challenge is required.'
invalid_redirect_uri: "The requested redirect uri is malformed or doesn't match client redirect URI."
unauthorized_client: 'The client is not authorized to perform this request using this method.'
access_denied: 'The resource owner or authorization server denied the request.'
invalid_scope: 'The requested scope is invalid, unknown, or malformed.'
invalid_code_challenge_method:
zero: 'The authorization server does not support PKCE as there are no accepted code_challenge_method values.'
one: 'The code_challenge_method must be %{challenge_methods}.'
other: 'The code_challenge_method must be one of %{challenge_methods}.'
server_error: 'The authorization server encountered an unexpected condition which prevented it from fulfilling the request.'
temporarily_unavailable: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.'
# Configuration error messages
credential_flow_not_configured: 'Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.'
resource_owner_authenticator_not_configured: 'Resource Owner find failed due to Doorkeeper.configure.resource_owner_authenticator being unconfigured.'
admin_authenticator_not_configured: 'Access to admin panel is forbidden due to Doorkeeper.configure.admin_authenticator being unconfigured.'
# Access grant errors
unsupported_response_type: 'The authorization server does not support this response type.'
unsupported_response_mode: 'The authorization server does not support this response mode.'
# Access token errors
invalid_client: 'Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.'
invalid_grant: 'The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.'
unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.'
invalid_token:
revoked: "The access token was revoked"
expired: "The access token expired"
unknown: "The access token is invalid"
revoke:
unauthorized: "You are not authorized to revoke this token"
forbidden_token:
missing_scope: 'Access to this resource requires scope "%{oauth_scopes}".'
flash:
applications:
create:
notice: 'Application created.'
destroy:
notice: 'Application deleted.'
update:
notice: 'Application updated.'
authorized_applications:
destroy:
notice: 'Application revoked.'
layouts:
admin:
title: 'Doorkeeper'
nav:
oauth2_provider: 'OAuth2 Provider'
applications: 'Applications'
home: 'Home'
application:
title: 'OAuth authorization required'

View file

@ -0,0 +1,75 @@
---
en:
settings:
api_keys_controller:
success: "Your API key has been created successfully"
revoked_successfully: "API key has been revoked successfully"
revoke_failed: "Failed to revoke API key"
scope_descriptions:
read_accounts: "View Accounts"
read_transactions: "View Transactions"
read_balances: "View Balances"
write_transactions: "Create Transactions"
api_keys:
show:
title: "API Key Management"
no_api_key:
title: "Create Your API Key"
description: "Get programmatic access to your Maybe data with a secure API key."
what_you_can_do: "What you can do with the API:"
feature_1: "Access your account data programmatically"
feature_2: "Build custom integrations and applications"
feature_3: "Automate data retrieval and analysis"
security_note_title: "Security First"
security_note: "Your API key will have restricted permissions based on the scopes you select. You can only have one active API key at a time."
create_api_key: "Create API Key"
current_api_key:
title: "Your API Key"
description: "Your active API key is ready to use. Keep it secure and never share it publicly."
active: "Active"
key_name: "Name"
created_at: "Created"
last_used: "Last Used"
expires: "Expires"
ago: "ago"
never_used: "Never used"
never_expires: "Never expires"
permissions: "Permissions"
usage_instructions_title: "How to use your API key"
usage_instructions: "Include your API key in the X-Api-Key header when making requests to the Maybe API:"
regenerate_key: "Create New Key"
revoke_key: "Revoke Key"
revoke_confirmation: "Are you sure you want to revoke this API key? This action cannot be undone and will immediately disable all applications using this key."
new:
title: "Create API Key"
create_new_key: "Create New API Key"
description: "Configure your new API key with a descriptive name and appropriate permissions."
name_label: "API Key Name"
name_placeholder: "e.g., Production App, Analytics Dashboard"
name_help: "Choose a descriptive name to help you identify this key's purpose."
permissions_label: "Permissions"
permissions_help: "Select the permissions your API key needs. You can always create a new key with different permissions."
scope_details:
read_accounts: "View account information, balances, and account-level data"
read_transactions: "View transaction data, categories, and transaction details"
read_balances: "View historical balance data and account value trends"
write_transactions: "Create and update transaction records (coming soon)"
security_warning_title: "Important Security Notice"
security_warning: "Your API key will be shown only once after creation. Store it securely and never share it publicly. If you lose it, you'll need to create a new one."
create_key: "Create API Key"
cancel: "Cancel"
created:
title: "API Key Created"
success_title: "API Key Created Successfully"
success_description: "Your new API key is ready to use. Make sure to copy it now as you won't be able to see it again."
your_api_key: "Your API Key"
key_name: "Name"
permissions: "Permissions"
critical_warning_title: "⚠️ Critical: Save Your API Key Now"
critical_warning_1: "This is the only time you'll see your API key in plain text."
critical_warning_2: "Copy and store it securely in your password manager or application."
critical_warning_3: "If you lose this key, you'll need to create a new one."
usage_instructions_title: "Quick Start"
usage_instructions: "Use your API key by including it in the X-Api-Key header:"
copy_key: "Copy API Key"
continue: "Continue to API Key Settings"

View file

@ -70,6 +70,7 @@ en:
page_title: Security
settings_nav:
accounts_label: Accounts
api_key_label: API Key
billing_label: Billing
categories_label: Categories
feedback_label: Feedback

View file

@ -2,6 +2,7 @@ require "sidekiq/web"
require "sidekiq/cron/web"
Rails.application.routes.draw do
use_doorkeeper
# MFA routes
resource :mfa, controller: "mfa", only: [ :new, :create ] do
get :verify
@ -55,6 +56,7 @@ Rails.application.routes.draw do
end
resource :billing, only: :show
resource :security, only: :show
resource :api_key, only: [ :show, :new, :create, :destroy ]
end
resource :subscription, only: %i[new show create] do
@ -180,6 +182,38 @@ Rails.application.routes.draw do
get :accept, on: :member
end
# API routes
namespace :api do
namespace :v1 do
# Authentication endpoints
post "auth/signup", to: "auth#signup"
post "auth/login", to: "auth#login"
post "auth/refresh", to: "auth#refresh"
# Production API endpoints
resources :accounts, only: [ :index ]
resources :transactions, only: [ :index, :show, :create, :update, :destroy ]
resource :usage, only: [ :show ], controller: "usage"
resources :chats, only: [ :index, :show, :create, :update, :destroy ] do
resources :messages, only: [ :create ] do
post :retry, on: :collection
end
end
# Test routes for API controller testing (only available in test environment)
if Rails.env.test?
get "test", to: "test#index"
get "test_not_found", to: "test#not_found"
get "test_family_access", to: "test#family_access"
get "test_scope_required", to: "test#scope_required"
get "test_multiple_scopes_required", to: "test#multiple_scopes_required"
end
end
end
resources :currencies, only: %i[show]
resources :impersonation_sessions, only: [ :create ] do

View file

@ -0,0 +1,99 @@
# frozen_string_literal: true
class CreateDoorkeeperTables < ActiveRecord::Migration[7.2]
def change
create_table :oauth_applications do |t|
t.string :name, null: false
t.string :uid, null: false
# Remove `null: false` or use conditional constraint if you are planning to use public clients.
t.string :secret, null: false
# Remove `null: false` if you are planning to use grant flows
# that doesn't require redirect URI to be used during authorization
# like Client Credentials flow or Resource Owner Password.
t.text :redirect_uri, null: false
t.string :scopes, null: false, default: ''
t.boolean :confidential, null: false, default: true
t.timestamps null: false
end
add_index :oauth_applications, :uid, unique: true
create_table :oauth_access_grants do |t|
t.references :resource_owner, null: false
t.references :application, null: false
t.string :token, null: false
t.integer :expires_in, null: false
t.text :redirect_uri, null: false
t.string :scopes, null: false, default: ''
t.datetime :created_at, null: false
t.datetime :revoked_at
end
add_index :oauth_access_grants, :token, unique: true
add_foreign_key(
:oauth_access_grants,
:oauth_applications,
column: :application_id
)
create_table :oauth_access_tokens do |t|
t.references :resource_owner, index: true
# Remove `null: false` if you are planning to use Password
# Credentials Grant flow that doesn't require an application.
t.references :application, null: false
# If you use a custom token generator you may need to change this column
# from string to text, so that it accepts tokens larger than 255
# characters. More info on custom token generators in:
# https://github.com/doorkeeper-gem/doorkeeper/tree/v3.0.0.rc1#custom-access-token-generator
#
# t.text :token, null: false
t.string :token, null: false
t.string :refresh_token
t.integer :expires_in
t.string :scopes
t.datetime :created_at, null: false
t.datetime :revoked_at
# The authorization server MAY issue a new refresh token, in which case
# *the client MUST discard the old refresh token* and replace it with the
# new refresh token. The authorization server MAY revoke the old
# refresh token after issuing a new refresh token to the client.
# @see https://datatracker.ietf.org/doc/html/rfc6749#section-6
#
# Doorkeeper implementation: if there is a `previous_refresh_token` column,
# refresh tokens will be revoked after a related access token is used.
# If there is no `previous_refresh_token` column, previous tokens are
# revoked as soon as a new access token is created.
#
# Comment out this line if you want refresh tokens to be instantly
# revoked after use.
t.string :previous_refresh_token, null: false, default: ""
end
add_index :oauth_access_tokens, :token, unique: true
# See https://github.com/doorkeeper-gem/doorkeeper/issues/1592
if ActiveRecord::Base.connection.adapter_name == "SQLServer"
execute <<~SQL.squish
CREATE UNIQUE NONCLUSTERED INDEX index_oauth_access_tokens_on_refresh_token ON oauth_access_tokens(refresh_token)
WHERE refresh_token IS NOT NULL
SQL
else
add_index :oauth_access_tokens, :refresh_token, unique: true
end
add_foreign_key(
:oauth_access_tokens,
:oauth_applications,
column: :application_id
)
# Uncomment below to ensure a valid reference to the resource owner's table
# add_foreign_key :oauth_access_grants, <model>, column: :resource_owner_id
# add_foreign_key :oauth_access_tokens, <model>, column: :resource_owner_id
end
end

View file

@ -0,0 +1,9 @@
class FixDoorkeeperResourceOwnerIdForUuid < ActiveRecord::Migration[7.1]
def up
change_column :oauth_access_tokens, :resource_owner_id, :string
end
def down
change_column :oauth_access_tokens, :resource_owner_id, :integer
end
end

View file

@ -0,0 +1,17 @@
class CreateApiKeys < ActiveRecord::Migration[7.2]
def change
create_table :api_keys, id: :uuid do |t|
t.string :key
t.string :name
t.references :user, null: false, foreign_key: true, type: :uuid
t.json :scopes
t.datetime :last_used_at
t.datetime :expires_at
t.datetime :revoked_at
t.timestamps
end
add_index :api_keys, :key
add_index :api_keys, :revoked_at
end
end

View file

@ -0,0 +1,6 @@
class AddDisplayKeyToApiKeys < ActiveRecord::Migration[7.2]
def change
add_column :api_keys, :display_key, :string, null: false
add_index :api_keys, :display_key, unique: true
end
end

View file

@ -0,0 +1,5 @@
class RemoveKeyFromApiKeys < ActiveRecord::Migration[7.2]
def change
remove_column :api_keys, :key, :string
end
end

View file

@ -0,0 +1,5 @@
class RemoveKeyIndexFromApiKeys < ActiveRecord::Migration[7.2]
def change
remove_index :api_keys, :key if index_exists?(:api_keys, :key)
end
end

View file

@ -0,0 +1,9 @@
class FixDoorkeeperAccessGrantsResourceOwnerIdForUuid < ActiveRecord::Migration[7.2]
def up
change_column :oauth_access_grants, :resource_owner_id, :string
end
def down
change_column :oauth_access_grants, :resource_owner_id, :bigint
end
end

Some files were not shown because too many files have changed in this diff Show more