diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc index 46d54ed4233..0be4b3d5564 100644 --- a/.cursor/rules/project-conventions.mdc +++ b/.cursor/rules/project-conventions.mdc @@ -14,8 +14,8 @@ This rule serves as high-level documentation for how you should write code in th - TailwindCSS for styles - Lucide Icons for icons - OpenAI for AI chat -- Database: PostgreSQL -- Jobs: Sidekiq + Redis +- Database: Sqlite3 +- Jobs: SolidQueue - External - Payments: Stripe - User bank data syncing: Plaid diff --git a/CLAUDE.md b/CLAUDE.md index 4fc9fcf107d..9ba9d9835d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Common Development Commands ### Development Server -- `bin/dev` - Start development server (Rails, Sidekiq, Tailwind CSS watcher) +- `bin/dev` - Start development server (Rails, Solid queue, Tailwind CSS watcher) - `bin/rails server` - Start Rails server only - `bin/rails console` - Open Rails console @@ -126,11 +126,11 @@ Provider support notes: - Lunchflow: does not currently store pending metadata. ### Background Processing -Sidekiq handles asynchronous tasks: +SolidQueue handles asynchronous tasks: - Account syncing (`SyncJob`) - Import processing (`ImportJob`) - AI chat responses (`AssistantResponseJob`) -- Scheduled maintenance via sidekiq-cron +- Scheduled maintenance via SolidQueue ### Frontend Architecture - **Hotwire Stack**: Turbo + Stimulus for reactive UI without heavy JavaScript diff --git a/README.md b/README.md index 809f013cbbe..d21333a6373 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,6 @@ The instructions below are for developers to get started with contributing to th ### Requirements - See `.ruby-version` file for required Ruby version -- PostgreSQL >9.3 (latest stable version recommended) -- Redis > 5.4 (latest stable version recommended) ### Getting Started ```sh diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 8415947fc05..50dbe7bd883 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -4,7 +4,7 @@ class Connection < ActionCable::Connection::Base private def report_error(e) - Sentry.capture_exception(e) + Rails.error.report(error) end end end diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 6d5e6b9fce5..befa2ef8a4b 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -5,7 +5,6 @@ class CategoriesController < ApplicationController def index @categories = Current.family.categories.alphabetically - render layout: "settings" end @@ -15,8 +14,12 @@ def new end def create - @category = Current.family.categories.new(category_params) - + params = if category_params[:parent_id].blank? + category_params.except(:parent_id) + else + category_params + end + @category = Current.family.categories.new(params) if @category.save @transaction.update(category_id: @category.id) if @transaction diff --git a/app/controllers/concerns/onboardable.rb b/app/controllers/concerns/onboardable.rb index 60655094015..f2e69caf4c7 100644 --- a/app/controllers/concerns/onboardable.rb +++ b/app/controllers/concerns/onboardable.rb @@ -25,6 +25,7 @@ def redirectable_path?(path) 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?("/jobs") return false if path.starts_with?("/api") # Exclude API endpoints from onboarding redirects [ diff --git a/app/controllers/concerns/self_hostable.rb b/app/controllers/concerns/self_hostable.rb index 3631571aea9..a863f17026b 100644 --- a/app/controllers/concerns/self_hostable.rb +++ b/app/controllers/concerns/self_hostable.rb @@ -3,8 +3,6 @@ module SelfHostable included do helper_method :self_hosted?, :self_hosted_first_login? - - prepend_before_action :verify_self_host_config end private @@ -15,29 +13,4 @@ def self_hosted? def self_hosted_first_login? self_hosted? && User.count.zero? end - - def verify_self_host_config - return unless self_hosted? - - # Special handling for Redis configuration error page - if controller_name == "pages" && action_name == "redis_configuration_error" - # If Redis is now working, redirect to home - if redis_connected? - redirect_to root_path, notice: "Redis is now configured properly! You can now setup your Sure application." - end - - return - end - - unless redis_connected? - redirect_to redis_configuration_error_path - end - end - - def redis_connected? - Redis.new.ping - true - rescue Redis::CannotConnectError - false - end end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index e0802c1c5b8..66b12fba0ca 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,9 +1,6 @@ class PagesController < ApplicationController include Periodable - skip_authentication only: %i[redis_configuration_error privacy terms] - before_action :ensure_intro_guest!, only: :intro - def dashboard if Current.user&.ui_layout_intro? redirect_to chats_path and return @@ -62,18 +59,6 @@ def feedback render layout: "settings" end - def redis_configuration_error - render layout: "blank" - end - - def privacy - render layout: "blank" - end - - def terms - render layout: "blank" - end - private def preferences_params prefs = params.require(:preferences) diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index e50325dcd0e..c1e2271a3e0 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -3,57 +3,52 @@ class WebhooksController < ApplicationController skip_authentication def plaid - webhook_body = request.body.read - plaid_verification_header = request.headers["Plaid-Verification"] + Rails.error.handle(fallback: -> { + render json: { error: "Invalid webhook:" }, status: :bad_request + }) do + webhook_body = request.body.read + plaid_verification_header = request.headers["Plaid-Verification"] - client = Provider::Registry.plaid_provider_for_region(:us) + client = Provider::Registry.plaid_provider_for_region(:us) - client.validate_webhook!(plaid_verification_header, webhook_body) + client.validate_webhook!(plaid_verification_header, webhook_body) - PlaidItem::WebhookProcessor.new(webhook_body).process + PlaidItem::WebhookProcessor.new(webhook_body).process - render json: { received: true }, status: :ok - rescue => error - Sentry.capture_exception(error) - Rails.logger.error("Webhook error: #{error.class} - #{error.message}") - render json: { error: "Invalid webhook" }, status: :bad_request + render json: { received: true }, status: :ok + end end def plaid_eu - webhook_body = request.body.read - plaid_verification_header = request.headers["Plaid-Verification"] + Rails.error.handle(fallback: -> { + render json: { error: "Invalid webhook" }, status: :bad_request + }) do + webhook_body = request.body.read + plaid_verification_header = request.headers["Plaid-Verification"] - client = Provider::Registry.plaid_provider_for_region(:eu) + client = Provider::Registry.plaid_provider_for_region(:eu) - client.validate_webhook!(plaid_verification_header, webhook_body) + client.validate_webhook!(plaid_verification_header, webhook_body) - PlaidItem::WebhookProcessor.new(webhook_body).process + PlaidItem::WebhookProcessor.new(webhook_body).process - render json: { received: true }, status: :ok - rescue => error - Sentry.capture_exception(error) - Rails.logger.error("Webhook error: #{error.class} - #{error.message}") - render json: { error: "Invalid webhook" }, status: :bad_request + render json: { received: true }, status: :ok + end end def stripe stripe_provider = Provider::Registry.get_provider(:stripe) - begin + Rails.error.handle(JSON::ParserError, Stripe::SignatureVerificationError, fallback: -> { + Rails.logger.error "Stripe webhook processing error" + head :bad_request + }) do webhook_body = request.body.read sig_header = request.env["HTTP_STRIPE_SIGNATURE"] stripe_provider.process_webhook_later(webhook_body, sig_header) head :ok - rescue JSON::ParserError => error - Sentry.capture_exception(error) - Rails.logger.error "JSON parser error: #{error.message}" - head :bad_request - rescue Stripe::SignatureVerificationError => error - Sentry.capture_exception(error) - Rails.logger.error "Stripe signature verification error: #{error.message}" - head :bad_request end end end diff --git a/app/jobs/seed_dump_job.rb b/app/jobs/seed_dump_job.rb new file mode 100644 index 00000000000..de78c8fe7c9 --- /dev/null +++ b/app/jobs/seed_dump_job.rb @@ -0,0 +1,57 @@ +class SeedDumpJob < ApplicationJob + queue_as :scheduled + + def perform + return if Rails.env.development? + + models = [ + "Setting", + "ActiveStorage::VariantRecord", + "ActiveStorage::Attachment", + "ActiveStorage::Blob", + "Security::Price", + "Rule::Condition", + "Rule::Action", + "TransactionImport", + "Import::Row", + "Valuation", + "User", + "Transfer", + "Transaction", + "Trade", + "Tagging", + "Sync", + "Session", + "Security", + "Rule", + "RejectedTransfer", + "Investment", + "Holding", + "Family", + "ExchangeRate", + "Entry", + "Depository", + "DataEnrichment", + "Crypto", + "CreditCard", + "Chat", + "BudgetCategory", + "Budget", + "Balance", + "Account", + "Tag", + "Category", + "Import::TagMapping", + "Import::CategoryMapping", + "ToolCall::Function", + "UserMessage", + "AssistantMessage" + ].join(",") + SeedDump.dump_using_environment({ + "IMPORT" => true, + "FILE" => "db/seeds/db_restore.rb", + "EXCLUDE" => "", + "MODELS" => models + }) + end +end diff --git a/app/models/sync.rb b/app/models/sync.rb index d1ba07a26db..b4acc4dec54 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -60,10 +60,10 @@ def clean end def perform - Rails.logger.tagged("Sync", id, syncable_type, syncable_id) do - # This can happen on server restarts or if Sidekiq enqueues a duplicate job + Rails.event.tagged("Sync", id, syncable_type, syncable_id) do + # This can happen on server restarts or if ~~Sidekiq~~ SolidQueue enqueues a duplicate job unless may_start? - Rails.logger.warn("Sync #{id} is not in a valid state (#{aasm.from_state}) to start. Skipping sync.") + Rails.event.notify("Sync", { id: id, syncable_type: syncable_type, syncable_id: syncable_id, message: "not in a valid state to start" }) return end @@ -88,6 +88,7 @@ def perform start! begin + Rails.event.notify("Performing sync", { syncable_type: syncable_type, syncable_id: syncable.id }) syncable.perform_sync(self) rescue => e fail! @@ -160,27 +161,25 @@ def all_children_finalized? end def perform_post_sync - Rails.logger.info("Performing post-sync for #{syncable_type} (#{syncable.id})") + Rails.event.notify("Performing post-sync", { syncable_type: syncable_type, syncable_id: syncable.id }) syncable.perform_post_sync syncable.broadcast_sync_complete rescue => e - Rails.logger.error("Error performing post-sync for #{syncable_type} (#{syncable.id}): #{e.message}") + Rails.event.notify("Error performing post-sync", { syncable_type: syncable_type, syncable_id: syncable.id, error: e.message }) report_error(e) end def report_error(error) - Sentry.capture_exception(error) do |scope| - scope.set_tags(sync_id: id) - end + Rails.error.report(error, context: { sync_id: id }) end def report_warnings todays_sync_count = syncable.syncs.where(created_at: Date.current.all_day).count - if todays_sync_count > 10 - Sentry.capture_exception( + if todays_sync_count > 100 + Rails.error.report( Error.new("#{syncable_type} (#{syncable.id}) has exceeded 10 syncs today (count: #{todays_sync_count})"), - level: :warning + context: { count: todays_sync_count, syncable_type: syncable_type, syncable_id: syncable.id } ) end end diff --git a/app/views/impersonation_sessions/_super_admin_bar.html.erb b/app/views/impersonation_sessions/_super_admin_bar.html.erb index a09a8250fa3..105e26e15fd 100644 --- a/app/views/impersonation_sessions/_super_admin_bar.html.erb +++ b/app/views/impersonation_sessions/_super_admin_bar.html.erb @@ -4,7 +4,7 @@ Super Admin
- <%= link_to "Jobs", sidekiq_web_url, class: "text-white underline hover:text-gray-100" %> + <%= link_to "Jobs", "/jobs", class: "text-white underline hover:text-gray-100" %>
diff --git a/app/views/pages/redis_configuration_error.html.erb b/app/views/pages/redis_configuration_error.html.erb deleted file mode 100644 index a95e00862b4..00000000000 --- a/app/views/pages/redis_configuration_error.html.erb +++ /dev/null @@ -1,59 +0,0 @@ -<% content_for :title, "Redis Configuration Required - Sure" %> - -
-
-
- -
-
- <%= icon "alert-triangle", class: "w-8 h-8 text-red-600" %> -
-

Redis Configuration Required

-

Your self-hosted Sure installation needs Redis to be properly configured.

-
- - -
-
-
- <%= icon "info", class: "w-5 h-5 text-amber-600 mt-0.5 mr-3 flex-shrink-0" %> -
-

Why is Redis required?

-

Sure uses Redis to power Sidekiq background jobs for tasks like syncing account data, processing imports, and other background operations that keep your financial data up to date.

-
-
-
- - -
- <%= render DS::Link.new( - text: "View Setup Guide", - href: "https://github.com/we-promise/sure/blob/main/docs/hosting/docker.md", - variant: "primary", - size: "lg", - icon: "external-link", - full_width: true, - target: "_blank", - rel: "noopener noreferrer" - ) %> -

Follow our complete Docker setup guide to configure Redis

-
-
- - -
-
-

Once you've configured Redis, refresh this page to continue.

- <%= render DS::Button.new( - text: "Refresh Page", - variant: "secondary", - icon: "refresh-cw", - type: "button", - full_width: true, - onclick: "window.location.reload()" - ) %> -
-
-
-
-
diff --git a/config/brakeman.ignore b/config/brakeman.ignore index ca044eb6ce4..90e2d2eee67 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -161,41 +161,7 @@ 95 ], "note": "Uses similar pattern to Rails internal form builder" - }, - { - "warning_type": "Dynamic Render Path", - "warning_code": 15, - "fingerprint": "fb6f7abeabc405d6882ffd41dbe8016403ef39307a5c6b4cd7b18adfaf0c24bf", - "check_name": "Render", - "message": "Render path contains parameter value", - "file": "app/views/import/configurations/show.html.erb", - "line": 34, - "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", - "code": "render(partial => permitted_import_configuration_path(Current.family.imports.find(params[:import_id])), { :locals => ({ :import => Current.family.imports.find(params[:import_id]) }) })", - "render_path": [ - { - "type": "controller", - "class": "Import::ConfigurationsController", - "method": "show", - "line": 7, - "file": "app/controllers/import/configurations_controller.rb", - "rendered": { - "name": "import/configurations/show", - "file": "app/views/import/configurations/show.html.erb" - } - } - ], - "location": { - "type": "template", - "template": "import/configurations/show" - }, - "user_input": "params[:import_id]", - "confidence": "Weak", - "cwe_id": [ - 22 - ], - "note": "" } ], - "brakeman_version": "7.1.0" + "brakeman_version": "8.0.4" } diff --git a/config/honeybadger.yml b/config/honeybadger.yml new file mode 100644 index 00000000000..685e2bb7ec8 --- /dev/null +++ b/config/honeybadger.yml @@ -0,0 +1,30 @@ +--- +# For more options, see https://docs.honeybadger.io/lib/ruby/gem-reference/configuration + +api_key: "<%= ENV["HONEYBADGER_API_KEY"] %>" + +# The environment your app is running in. +env: "<%= Rails.env %>" + +# The absolute path to your project folder. +root: "<%= Rails.root.to_s %>" + +# Honeybadger won't report errors in these environments. +development_environments: +- test +- development +- cucumber + +# By default, Honeybadger won't report errors in the development_environments. +# You can override this by explicitly setting report_data to true. +# report_data: true + +# The current Git revision of your project. Defaults to the last commit hash. +# revision: null + +# Enable verbose debug logging (useful for troubleshooting). +debug: false + +# Enable Honeybadger Insights +insights: + enabled: true diff --git a/config/initializers/litestream.rb b/config/initializers/litestream.rb new file mode 100644 index 00000000000..0705b5b68e6 --- /dev/null +++ b/config/initializers/litestream.rb @@ -0,0 +1,45 @@ +# Use this hook to configure the litestream-ruby gem. +# All configuration options will be available as environment variables, e.g. +# config.replica_bucket becomes LITESTREAM_REPLICA_BUCKET +# This allows you to configure Litestream using Rails encrypted credentials, +# or some other mechanism where the values are only available at runtime. + +Rails.application.configure do + # Configure Litestream through environment variables. Use Rails encrypted credentials for secrets. + # litestream_credentials = Rails.application.credentials.litestream + + # Replica-specific bucket location. This will be your bucket's URL without the `https://` prefix. + # For example, if you used DigitalOcean Spaces, your bucket URL could look like: + # + # https://myapp.fra1.digitaloceanspaces.com + # + # And so you should set your `replica_bucket` to: + # + # myapp.fra1.digitaloceanspaces.com + # + # config.litestream.replica_bucket = litestream_credentials&.replica_bucket + # + # Replica-specific authentication key. Litestream needs authentication credentials to access your storage provider bucket. + # config.litestream.replica_key_id = litestream_credentials&.replica_key_id + # + # Replica-specific secret key. Litestream needs authentication credentials to access your storage provider bucket. + # config.litestream.replica_access_key = litestream_credentials&.replica_access_key + # + # Replica-specific region. Set the bucket’s region. Only used for AWS S3 & Backblaze B2. + # config.litestream.replica_region = "us-east-1" + # + # Replica-specific endpoint. Set the endpoint URL of the S3-compatible service. Only required for non-AWS services. + # config.litestream.replica_endpoint = "endpoint.your-objectstorage.com" + + # Configure the default Litestream config path + # config.config_path = Rails.root.join("config", "litestream.yml") + + # Configure the Litestream dashboard + # + # Set the default base controller class + config.litestream.base_controller_class = "ActionController::Base" + # + # Set authentication credentials for Litestream dashboard + # config.litestream.username = litestream_credentials&.username + # config.litestream.password = litestream_credentials&.password +end diff --git a/config/initializers/rails_db.rb b/config/initializers/rails_db.rb new file mode 100644 index 00000000000..06ffbf2416d --- /dev/null +++ b/config/initializers/rails_db.rb @@ -0,0 +1,30 @@ +if Object.const_defined?("RailsDb") + RailsDb.setup do |config| + # # enabled or not + config.enabled = true + + # # automatic engine routes mounting + config.automatic_routes_mount = true + + # set tables which you want to hide ONLY + # config.black_list_tables = [ "users", "accounts" ] + + # set tables which you want to show ONLY + # config.white_list_tables = [ "posts", "comments" ] + + # # Enable http basic authentication + config.http_basic_authentication_enabled = true if Rails.env.production? + + # # Enable http basic authentication + config.http_basic_authentication_user_name = "rails_db" if Rails.env.production? + + # # Enable http basic authentication + config.http_basic_authentication_password = ENV.fetch("RAILS_DB_PASSWORD", "rails_db") if Rails.env.production? + + # # Enable verify access proc + # config.verify_access_proc = proc { |controller| true } + + # # Sandbox mode (only read-only operations) + # config.sandbox = false + end +end diff --git a/config/initializers/rswag_ui.rb b/config/initializers/rswag_ui.rb new file mode 100644 index 00000000000..417b5a63294 --- /dev/null +++ b/config/initializers/rswag_ui.rb @@ -0,0 +1,3 @@ +Rswag::Ui.configure do |c| + c.swagger_endpoint "/api-docs.yaml", "Maybe Finance API V1" +end diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index 8e31567d49c..84363148ed0 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -22,5 +22,7 @@ config.release = Rails.root.join(".sure-version").read.strip rescue nil config.profiler_class = Sentry::Vernier::Profiler + config.enable_logs = true + config.enabled_patches = [ :logger, :http, :puma ] end end diff --git a/config/initializers/subscribers.rb b/config/initializers/subscribers.rb new file mode 100644 index 00000000000..9ab6ca4217b --- /dev/null +++ b/config/initializers/subscribers.rb @@ -0,0 +1,36 @@ +# Event Subscriber +class LogEventSubscriber + def emit(event) + payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(" ") + + source_location = event[:source_location] + + log = "[#{event[:name]}] #{payload} at #{source_location[:filepath]}:#{source_location[:lineno]}" + Rails.logger.info(log) + Honeybadger.event(event[:name], payload: event[:payload], source_location: source_location) + end +end + +# Error Subscriber +class LogErrorSubscriber + def report(error, handled:, severity:, context:, source:) + error_message = "#{error.class}: #{error.message}" + error_message += " (handled: #{handled}, severity: #{severity}, source: #{source})" + + if context.any? + context_str = context.map { |k, v| "#{k}=#{v}" }.join(", ") + error_message += " [context: #{context_str}]" + end + + Rails.logger.error("[Error] #{error_message}") + + # Log backtrace in development + if Rails.env.development? && error.backtrace + Rails.logger.error("Backtrace:\n#{error.backtrace.first(10).join("\n")}") + end + end +end + +# Register subscribers +Rails.event.subscribe(LogEventSubscriber.new) +Rails.error.subscribe(LogErrorSubscriber.new) diff --git a/config/litestream.yml b/config/litestream.yml new file mode 100644 index 00000000000..aa3ee19e4cd --- /dev/null +++ b/config/litestream.yml @@ -0,0 +1,21 @@ +# This is the actual configuration file for litestream. +# +# You can either use the generated `config/initializers/litestream.rb` +# file to configure the litestream-ruby gem, which will populate these +# ENV variables when using the `rails litestream:replicate` command. +# +# Or, if you prefer, manually manage ENV variables and this configuration file. +# In that case, simply ensure that the ENV variables are set before running the +# `replicate` command. +# +# For more details, see: https://litestream.io/reference/config/ +dbs: + - path: storage/production_primary.sqlite3 + replicas: + - type: s3 + bucket: sure-backups + endpoint: $LITESTREAM_REPLICA_BUCKET_URL + path: storage/production_primary.sqlite3 + access-key-id: $LITESTREAM_ACCESS_KEY_ID + secret-access-key: $LITESTREAM_SECRET_ACCESS_KEY + validation-interval: 24h diff --git a/config/routes.rb b/config/routes.rb index 48116d5c492..38208a838ee 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,118 +1,6 @@ -require "sidekiq/web" -require "sidekiq/cron/web" - Rails.application.routes.draw do - resources :indexa_capital_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do - collection do - get :preload_accounts - get :select_accounts - post :link_accounts - get :select_existing_account - post :link_existing_account - end - - member do - post :sync - get :setup_accounts - post :complete_account_setup - end - end - resources :mercury_items, only: %i[index new create show edit update destroy] do - collection do - get :preload_accounts - get :select_accounts - post :link_accounts - get :select_existing_account - post :link_existing_account - end - - member do - post :sync - get :setup_accounts - post :complete_account_setup - end - end - - resources :coinbase_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do - collection do - get :preload_accounts - get :select_accounts - post :link_accounts - get :select_existing_account - post :link_existing_account - end - - member do - post :sync - get :setup_accounts - post :complete_account_setup - end - end + mount Rswag::Ui::Engine => "/api-docs" - resources :binance_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do - collection do - get :select_accounts - post :link_accounts - get :select_existing_account - post :link_existing_account - end - - member do - post :sync - get :setup_accounts - post :complete_account_setup - end - end - - resources :snaptrade_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do - collection do - get :preload_accounts - get :select_accounts - post :link_accounts - get :select_existing_account - post :link_existing_account - get :callback - end - - member do - post :sync - get :connect - get :setup_accounts - post :complete_account_setup - get :connections - delete :delete_connection - delete :delete_orphaned_user - end - end - - # CoinStats routes - resources :coinstats_items, only: [ :index, :new, :create, :update, :destroy ] do - collection do - post :link_wallet - post :link_exchange - end - member do - post :sync - end - end - - resources :enable_banking_items, only: [ :new, :create, :update, :destroy ] do - collection do - get :callback - post :link_accounts - get :select_existing_account - post :link_existing_account - end - member do - post :sync - get :select_bank - post :authorize - post :reauthorize - get :setup_accounts - post :complete_account_setup - post :new_connection - end - end use_doorkeeper # MFA routes resource :mfa, controller: "mfa", only: [ :new, :create ] do @@ -125,13 +13,8 @@ mount Lookbook::Engine, at: "/design-system" if Rails.env.development? - if Rails.env.development? - mount Rswag::Api::Engine => "/api-docs" - mount Rswag::Ui::Engine => "/api-docs" - end - - # Uses basic auth - see config/initializers/sidekiq.rb - mount Sidekiq::Web => "/sidekiq" + mount MissionControl::Jobs::Engine, at: "/jobs" + mount Litestream::Engine, at: "/litestream" if Rails.env.production? # AI chats resources :chats do @@ -550,11 +433,6 @@ post "stripe" end - get "redis-configuration-error", to: "pages#redis_configuration_error" - - # MCP server endpoint for external AI assistants (JSON-RPC 2.0) - post "mcp", to: "mcp#handle" - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check diff --git a/docs/hosting/docker.md b/docs/hosting/docker.md index 5ace300ab3d..8eb0fa6fe25 100644 --- a/docs/hosting/docker.md +++ b/docs/hosting/docker.md @@ -92,7 +92,6 @@ Fill in this file with the following variables: ```txt SECRET_KEY_BASE="replacemewiththegeneratedstringfromthepriorstep" -POSTGRES_PASSWORD="replacemewithyourdesireddatabasepassword" ``` We also recommend leaving YJIT disabled by default on smaller hosts. If you want to trade more memory for potentially better Ruby throughput, opt in explicitly: @@ -298,26 +297,3 @@ docker compose pull # This pulls the "latest" published image from GHCR docker compose build # This rebuilds the app with updates docker compose up --no-deps -d web worker # This restarts the app using the newest version ``` - -## Troubleshooting - -### ActiveRecord::DatabaseConnectionError - -If you are trying to get Sure started for the **first time** and run into database connection issues, it is likely because Docker has already initialized the Postgres database with a _different_ default role (usually from a previous attempt to start the app). - -If you run into this issue, you can optionally **reset the database**. - -**PLEASE NOTE: this will delete any existing data that you have in your Sure database, so proceed with caution.** For first-time users of the app just trying to get started, you're generally safe to run the commands below. - -By running the commands below, you will delete your existing Sure database and "reset" it. - -``` -docker compose down -docker volume rm sure_postgres-data # this is the name of the volume the DB is mounted to -docker compose up -docker compose exec db psql -U sure_user -d sure_development -c "SELECT 1;" # This will verify that the issue is fixed -``` - -### Slow `.csv` import (processing rows taking longer than expected) - -Importing comma-separated-value file(s) requires the `sure-worker` container to communicate with Redis. Check your worker logs for any unexpected errors, such as connection timeouts or Redis communication failures. diff --git a/public/api-docs.yaml b/public/api-docs.yaml new file mode 100644 index 00000000000..45392f7212e --- /dev/null +++ b/public/api-docs.yaml @@ -0,0 +1,1079 @@ +openapi: 3.0.3 +info: + title: Maybe Finance API + description: | + The Maybe Finance API allows external applications to interact with your financial data. + + ## Authentication + + The API supports two authentication methods: + + 1. **OAuth 2.0**: Pass the access token in the `Authorization` header as `Bearer ` + 2. **API Key**: Pass the API key in the `X-Api-Key` header + + ## Scopes + + - `read`: Read access to resources + - `read_write`: Full read and write access + + ## Rate Limiting + + API requests are subject to rate limiting based on your authentication method and tier. + version: 1.0.0 + contact: + name: Maybe Finance Support + url: https://maybe.co/support + +servers: + - url: https://api.maybe.co + description: Production server + - url: http://localhost:3000 + description: Local development + +security: + - bearerAuth: [] + - apiKeyAuth: [] + +paths: + /api/v1/auth/signup: + post: + summary: User signup + description: Create a new user account with OAuth application + tags: + - Authentication + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - user + - device + properties: + user: + type: object + required: + - email + - password + - first_name + - last_name + properties: + email: + type: string + format: email + password: + type: string + minLength: 8 + first_name: + type: string + last_name: + type: string + device: + type: object + required: + - device_id + - device_name + - device_type + - os_version + - app_version + properties: + device_id: + type: string + device_name: + type: string + device_type: + type: string + enum: [ios, android, web] + os_version: + type: string + app_version: + type: string + invite_code: + type: string + description: Required if invite codes are enabled + responses: + '201': + description: User created successfully + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + refresh_token: + type: string + token_type: + type: string + example: Bearer + expires_in: + type: integer + created_at: + type: integer + user: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + first_name: + type: string + last_name: + type: string + '403': + description: Invite code required or invalid + '422': + description: Validation failed + + /api/v1/auth/login: + post: + summary: User login + description: Authenticate and receive OAuth tokens + tags: + - Authentication + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - email + - password + - device + properties: + email: + type: string + format: email + password: + type: string + otp_code: + type: string + description: Required if MFA is enabled + device: + type: object + required: + - device_id + - device_name + - device_type + - os_version + - app_version + properties: + device_id: + type: string + device_name: + type: string + device_type: + type: string + os_version: + type: string + app_version: + type: string + responses: + '200': + description: Login successful + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + refresh_token: + type: string + token_type: + type: string + expires_in: + type: integer + user: + type: object + properties: + id: + type: string + email: + type: string + first_name: + type: string + last_name: + type: string + '401': + description: Invalid credentials or MFA required + + /api/v1/auth/refresh: + post: + summary: Refresh access token + description: Get a new access token using a refresh token + tags: + - Authentication + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - refresh_token + properties: + refresh_token: + type: string + device: + type: object + properties: + device_id: + type: string + responses: + '200': + description: Token refreshed successfully + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + refresh_token: + type: string + token_type: + type: string + expires_in: + type: integer + created_at: + type: integer + '400': + description: Refresh token required + '401': + description: Invalid refresh token + + /api/v1/accounts: + get: + summary: List accounts + description: Get all accounts belonging to the authenticated user's family + tags: + - Accounts + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - name: page + in: query + schema: + type: integer + default: 1 + - name: per_page + in: query + schema: + type: integer + default: 25 + maximum: 100 + responses: + '200': + description: List of accounts + content: + application/json: + schema: + type: object + properties: + accounts: + type: array + items: + $ref: '#/components/schemas/Account' + pagination: + $ref: '#/components/schemas/Pagination' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /api/v1/transactions: + get: + summary: List transactions + description: Get all transactions with optional filtering + tags: + - Transactions + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - name: page + in: query + schema: + type: integer + default: 1 + - name: per_page + in: query + schema: + type: integer + default: 25 + maximum: 100 + - name: account_id + in: query + schema: + type: string + format: uuid + - name: account_ids + in: query + schema: + type: array + items: + type: string + - name: category_id + in: query + schema: + type: string + format: uuid + - name: category_ids + in: query + schema: + type: array + items: + type: string + - name: merchant_id + in: query + schema: + type: string + format: uuid + - name: merchant_ids + in: query + schema: + type: array + items: + type: string + - name: start_date + in: query + schema: + type: string + format: date + - name: end_date + in: query + schema: + type: string + format: date + - name: min_amount + in: query + schema: + type: number + - name: max_amount + in: query + schema: + type: number + - name: type + in: query + schema: + type: string + enum: [income, expense] + - name: search + in: query + schema: + type: string + responses: + '200': + description: List of transactions + content: + application/json: + schema: + type: object + properties: + transactions: + type: array + items: + $ref: '#/components/schemas/Transaction' + pagination: + $ref: '#/components/schemas/Pagination' + '401': + $ref: '#/components/responses/Unauthorized' + + post: + summary: Create transaction + description: Create a new transaction + tags: + - Transactions + security: + - bearerAuth: [] + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - transaction + properties: + transaction: + type: object + required: + - account_id + - date + - amount + - name + properties: + account_id: + type: string + format: uuid + date: + type: string + format: date + amount: + type: number + name: + type: string + description: + type: string + notes: + type: string + currency: + type: string + nature: + type: string + enum: [income, inflow, expense, outflow] + category_id: + type: string + format: uuid + merchant_id: + type: string + format: uuid + tag_ids: + type: array + items: + type: string + responses: + '201': + description: Transaction created + content: + application/json: + schema: + $ref: '#/components/schemas/Transaction' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/UnprocessableEntity' + + /api/v1/transactions/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + + get: + summary: Get transaction + description: Get a single transaction by ID + tags: + - Transactions + security: + - bearerAuth: [] + - apiKeyAuth: [] + responses: + '200': + description: Transaction details + content: + application/json: + schema: + $ref: '#/components/schemas/Transaction' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + patch: + summary: Update transaction + description: Update an existing transaction + tags: + - Transactions + security: + - bearerAuth: [] + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + transaction: + type: object + properties: + date: + type: string + format: date + amount: + type: number + name: + type: string + description: + type: string + notes: + type: string + nature: + type: string + enum: [income, inflow, expense, outflow] + category_id: + type: string + format: uuid + merchant_id: + type: string + format: uuid + tag_ids: + type: array + items: + type: string + responses: + '200': + description: Transaction updated + content: + application/json: + schema: + $ref: '#/components/schemas/Transaction' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/UnprocessableEntity' + + delete: + summary: Delete transaction + description: Delete a transaction + tags: + - Transactions + security: + - bearerAuth: [] + - apiKeyAuth: [] + responses: + '200': + description: Transaction deleted + content: + application/json: + schema: + type: object + properties: + message: + type: string + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /api/v1/usage: + get: + summary: Get API usage + description: Get usage information for the current API key or OAuth token + tags: + - Usage + security: + - bearerAuth: [] + - apiKeyAuth: [] + responses: + '200': + description: Usage information + content: + application/json: + schema: + type: object + properties: + api_key: + type: object + properties: + name: + type: string + scopes: + type: array + items: + type: string + last_used_at: + type: string + format: date-time + created_at: + type: string + format: date-time + authentication_method: + type: string + message: + type: string + '400': + description: Invalid authentication method + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/chats: + get: + summary: List chats + description: Get all chats for the authenticated user + tags: + - Chats + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - name: page + in: query + schema: + type: integer + default: 1 + - name: per_page + in: query + schema: + type: integer + default: 20 + responses: + '200': + description: List of chats + content: + application/json: + schema: + type: object + properties: + chats: + type: array + items: + $ref: '#/components/schemas/Chat' + pagination: + $ref: '#/components/schemas/Pagination' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: AI features not enabled + + post: + summary: Create chat + description: Create a new chat with an optional initial message + tags: + - Chats + security: + - bearerAuth: [] + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title: + type: string + message: + type: string + description: Initial message to send to AI + model: + type: string + enum: [gpt-4, gpt-4-turbo, gpt-3.5-turbo] + default: gpt-4 + responses: + '201': + description: Chat created + content: + application/json: + schema: + $ref: '#/components/schemas/ChatWithMessages' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: AI features not enabled + '422': + $ref: '#/components/responses/UnprocessableEntity' + + /api/v1/chats/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + + get: + summary: Get chat + description: Get a single chat with its messages + tags: + - Chats + security: + - bearerAuth: [] + - apiKeyAuth: [] + responses: + '200': + description: Chat details with messages + content: + application/json: + schema: + $ref: '#/components/schemas/ChatWithMessages' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: AI features not enabled + '404': + $ref: '#/components/responses/NotFound' + + patch: + summary: Update chat + description: Update chat title + tags: + - Chats + security: + - bearerAuth: [] + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title: + type: string + responses: + '200': + description: Chat updated + content: + application/json: + schema: + $ref: '#/components/schemas/ChatWithMessages' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + delete: + summary: Delete chat + description: Delete a chat and all its messages + tags: + - Chats + security: + - bearerAuth: [] + - apiKeyAuth: [] + responses: + '204': + description: Chat deleted + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /api/v1/chats/{chat_id}/messages: + parameters: + - name: chat_id + in: path + required: true + schema: + type: string + format: uuid + + post: + summary: Create message + description: Send a message to the AI assistant + tags: + - Messages + security: + - bearerAuth: [] + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - content + properties: + content: + type: string + model: + type: string + enum: [gpt-4, gpt-4-turbo, gpt-3.5-turbo] + default: gpt-4 + responses: + '201': + description: Message created + content: + application/json: + schema: + $ref: '#/components/schemas/Message' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: AI features not enabled + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/UnprocessableEntity' + + /api/v1/chats/{chat_id}/messages/retry: + parameters: + - name: chat_id + in: path + required: true + schema: + type: string + format: uuid + + post: + summary: Retry last message + description: Retry generating the last AI assistant response + tags: + - Messages + security: + - bearerAuth: [] + - apiKeyAuth: [] + responses: + '202': + description: Retry initiated + content: + application/json: + schema: + type: object + properties: + message: + type: string + message_id: + type: string + format: uuid + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: AI features not enabled + '404': + $ref: '#/components/responses/NotFound' + '422': + description: No assistant message to retry + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: OAuth 2.0 access token + apiKeyAuth: + type: apiKey + in: header + name: X-Api-Key + description: API key for authentication + + schemas: + Account: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + balance: + type: string + currency: + type: string + classification: + type: string + enum: [asset, liability] + account_type: + type: string + + Transaction: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + amount: + type: string + currency: + type: string + date: + type: string + format: date + notes: + type: string + category: + type: object + properties: + id: + type: string + name: + type: string + merchant: + type: object + properties: + id: + type: string + name: + type: string + account: + type: object + properties: + id: + type: string + name: + type: string + tags: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + + Chat: + type: object + properties: + id: + type: string + format: uuid + title: + type: string + last_message_at: + type: string + format: date-time + message_count: + type: integer + error: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + ChatWithMessages: + allOf: + - $ref: '#/components/schemas/Chat' + - type: object + properties: + messages: + type: array + items: + $ref: '#/components/schemas/Message' + + Message: + type: object + properties: + id: + type: string + format: uuid + chat_id: + type: string + format: uuid + type: + type: string + enum: [user_message, assistant_message] + role: + type: string + enum: [user, assistant] + content: + type: string + model: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + ai_response_status: + type: string + description: Only present for user messages + ai_response_message: + type: string + description: Only present for user messages + tool_calls: + type: array + description: Only present for assistant messages + items: + type: object + properties: + id: + type: string + function_name: + type: string + function_arguments: + type: object + function_result: + type: object + created_at: + type: string + format: date-time + + Pagination: + type: object + properties: + page: + type: integer + per_page: + type: integer + total_count: + type: integer + total_pages: + type: integer + + Error: + type: object + properties: + error: + type: string + message: + type: string + details: + type: array + items: + type: string + + responses: + Unauthorized: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: unauthorized + message: Access token or API key is invalid, expired, or missing + + Forbidden: + description: Forbidden - Insufficient scope or permissions + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: insufficient_scope + message: This action requires the 'write' scope + + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: not_found + message: Resource not found + + UnprocessableEntity: + description: Validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: validation_failed + message: Validation failed + details: ["Name can't be blank"]