Jesse A. Green / notebook

Maybe — Administrator & Developer Guide

Generated June 2026 from maybe-finance/maybe at 77b5469

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

Maybe is a self-hosted personal finance platform built as a single Ruby on Rails 7.2 application backed by PostgreSQL and Redis, with Sidekiq for background work and a Hotwire (Turbo + Stimulus) front end. This guide is the operator's and developer's reference: how the system is built, every configuration variable, how to deploy and operate it, how it's secured, and how the code is organized.

For day-to-day usage (adding accounts, budgets, the AI assistant), see the companion Maybe — User Guide.

Note

Maybe reached its final public release (v0.6.0, defined in config/initializers/version.rb) and is no longer actively maintained upstream. It is fully functional and licensed under the AGPLv3. This guide documents the codebase at commit 77b5469.


1. Architecture Overview

Maybe is a "majestic monolith": one Rails application that serves both the web UI and a JSON API, with all heavy work pushed to a Sidekiq worker process. It runs in one of two modes, decided at boot from environment variables:

  • Self-hosted (SELF_HOSTED=true) — you run it; subscriptions are disabled, rate limits are generous, encryption keys auto-generate, and self-hosting settings appear in the UI.
  • Managed — the configuration the original hosted service used (billing on, tighter limits).

The mode is set in config/application.rb:

config.app_mode = (ENV["SELF_HOSTED"] == "true" || ENV["SELF_HOSTING_ENABLED"] == "true" ? "self_hosted" : "managed").inquiry

Runtime topology

                         ┌─────────────────────────────┐
        Browser ───────▶ │  web  (Puma, Rails 7.2)      │
        / API client     │  - HTML via Hotwire/Turbo    │
                         │  - JSON API /api/v1          │
                         │  - Rack::Attack rate limiting │
                         └───────┬─────────────┬─────────┘
                                 │             │
                    ┌────────────▼──┐     ┌────▼─────────┐
                    │ PostgreSQL 16 │     │  Redis        │
                    │ (UUID PKs,    │     │ - Sidekiq queue│
                    │  app data)    │     │ - ActionCable  │
                    └────────────▲──┘     │ - Rails cache  │
                                 │        └────▲──────────┘
                    ┌────────────┴───────────┐ │
                    │ worker (Sidekiq)        │─┘
                    │ - syncs, imports, AI    │
                    │ - sidekiq-cron schedule │
                    └────────────┬────────────┘
                                 │
              External providers ▼ (all optional, via Provider::Registry)
        Plaid (bank sync) · Synth (prices/FX) · OpenAI (assistant) ·
        Stripe (billing, managed only) · GitHub (release notes) ·
        S3 / Cloudflare R2 (file storage) · SMTP (email) · Sentry (errors)

Note

The web and worker containers run the same image with different commands (./bin/rails server vs. bundle exec sidekiq). They share the database and Redis. The worker is not optional: account syncs, CSV imports, AI responses, rule application, and all scheduled jobs execute there.

Component inventory

Component Path Role
Rails app config config/application.rb App mode, middleware, defaults
Routes config/routes.rb Web + API + webhooks + Sidekiq/Lookbook mounts
Controllers app/controllers/ Web (Turbo) controllers; api/v1/ JSON controllers
Domain models app/models/ "Fat models" — all business logic
Provider integrations app/models/provider/ Plaid, Synth, OpenAI, Stripe, GitHub registry
Background jobs app/jobs/ Sidekiq job classes
View components app/components/ Reusable ViewComponent UI
Stimulus controllers app/javascript/controllers/ Front-end interactivity
Initializers config/initializers/ Doorkeeper, Rack::Attack, Sidekiq, encryption, etc.
Migrations db/migrate/ Schema (≈199 migrations)

Technology stack

Layer Technology
Language / framework Ruby 3.4.4, Rails 7.2
Web server Puma
Database PostgreSQL (UUID primary keys)
Cache / queue / pubsub Redis (Sidekiq, ActionCable, Rails cache)
Background jobs Sidekiq + sidekiq-cron
Front end Hotwire (Turbo + Stimulus), ViewComponent, Importmap, Propshaft, Tailwind CSS v4, D3.js
Auth Session-based (bcrypt) for web; Doorkeeper OAuth2 + API keys for API; ROTP for MFA
Payments Stripe (managed mode)
Bank sync Plaid (US + EU)
Market data Synth
AI OpenAI (ruby-openai)
File storage Active Storage (Disk, Amazon S3, or Cloudflare R2)
Monitoring Sentry, Skylight, Logtail, Rack::MiniProfiler, Vernier

Core domain model

The data model centers on the Family (the tenant/household) which owns everything:

Family ──< User (member | admin | super_admin)
       ──< Account ──< Entry ──< {Transaction | Trade | Valuation}   (delegated type)
       │            ──< Holding ──> Security
       │            ──< Balance
       ──< Category (2-level, income | expense)
       ──< Tag, FamilyMerchant
       ──< Budget ──< BudgetCategory
       ──< Rule ──< Condition / Action
       ──< Import, FamilyExport, Invitation, PlaidItem
       ──< Subscription (managed mode)

Key design points, confirmed in app/models/:

  • Account uses Rails delegated types (Accountable::TYPES): Depository, Investment, Property, Vehicle, CreditCard, Loan, Crypto, OtherAsset, OtherLiability. Each maps to a balance_type of :cash, :investment, or :non_cash. Account lifecycle is an AASM state machine: active, draft, disabled, pending_deletion.
  • Entry is the polymorphic ledger line; its entryable is a Transaction, Trade, or Valuation. Amounts are signed (positive = outflow/expense, negative = inflow/income).
  • Category supports two levels, an income/expense classification, a default bootstrap set, and a fixed color palette.
  • Rule currently supports only the transaction resource type, with conditions (one level of compound nesting allowed) and at least one action.

Note

The codebase follows a deliberate "skinny controllers, fat models" convention (see the repo's CLAUDE.md): business logic lives in app/models/ as ActiveRecord models, concerns, and POROs, not in a service layer. When extending the app, put domain logic on the model that owns the question.


2. System Requirements

Resource Recommendation
CPU 1–2 vCPU is sufficient for a personal/household instance
Memory ~1 GB+; YJIT is enabled for performance (enable_yjit.rb)
Disk Image (~hundreds of MB) + PostgreSQL data volume + Active Storage (if Disk)
Docker Docker Engine + Docker Compose v2
PostgreSQL 16 (bundled in the Compose file)
Redis Latest (bundled in the Compose file)
Ruby (source dev only) 3.4.4 (see .ruby-version); PostgreSQL >9.3

Image build notes (from the Dockerfile):

  • Multi-stage build on ruby:3.4.4-slim; runtime packages curl, libvips, postgresql-client, libyaml.
  • Runs as non-root user/group rails (uid/gid 1000).
  • Assets are precompiled at build time; bootsnap is precompiled for fast boot.
  • Entrypoint (bin/docker-entrypoint) prepares the database before starting.
  • BUILD_COMMIT_SHA is baked in so the running app can report its commit.

3. Configuration Reference

All configuration is via environment variables (read into compose.yml/.env). The complete supported set is documented in .env.example.

Required (self-hosting)

Variable Type Default Description
SELF_HOSTED bool true Enables self-hosted mode (disables billing, relaxes limits, auto-generates encryption keys).
SECRET_KEY_BASE string (sample value in Compose) Rails secret used to sign cookies and derive encryption keys. Generate your own with openssl rand -hex 64.

Important

SECRET_KEY_BASE is security-critical. The sample compose.example.yml ships a default value so the app runs out of the box — replace it for any instance reachable beyond localhost. It also seeds Active Record encryption keys (below), so changing it later invalidates previously encrypted data.

Database

Variable Default Description
DB_HOST localhost (db in Compose) PostgreSQL host
DB_PORT 5432 PostgreSQL port
POSTGRES_USER postgres / maybe_user DB user
POSTGRES_PASSWORD postgres DB password — set your own
POSTGRES_DB maybe_production Database name

Cache / jobs

Variable Default Description
REDIS_URL redis://localhost:6379/1 Redis for Sidekiq, ActionCable, cache
RAILS_MAX_THREADS 3 Puma threads / Sidekiq concurrency

Active Record encryption (optional)

If unset on a self-hosted instance, these are auto-derived from SECRET_KEY_BASE (see config/initializers/active_record_encryption.rb), so encryption works with zero setup and stays stable across restarts.

Variable Description
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Primary encryption key
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Deterministic key (for queryable encrypted columns)
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Key-derivation salt

Tip

To manage keys yourself, generate them with rails db:encryption:init and set all three variables. Provide all three or none — if any is blank, all three are regenerated from SECRET_KEY_BASE.

Email (SMTP)

Variable Default Description
SMTP_ADDRESS (empty) SMTP host (e.g. Resend)
SMTP_PORT 465 SMTP port
SMTP_USERNAME / SMTP_PASSWORD (empty) SMTP credentials
SMTP_TLS_ENABLED true Use TLS
EMAIL_SENDER (empty) From address
APP_DOMAIN (empty) Domain used to build links in emails

TLS / proxy

Variable Description
RAILS_FORCE_SSL Force HTTPS redirects (set false behind a TLS-terminating proxy on a private network)
RAILS_ASSUME_SSL Tell Rails the proxy already terminated TLS
DISABLE_SSL Disable SSL enforcement entirely (local use)

External providers (all optional)

Variable(s) Provider Purpose
SYNTH_API_KEY Synth Historical exchange rates and security prices
OPENAI_ACCESS_TOKEN OpenAI AI assistant + AI-assisted rules (billed by OpenAI)
PLAID_CLIENT_ID, PLAID_SECRET, PLAID_ENV Plaid (US) Bank account linking/sync (PLAID_ENV default sandbox)
PLAID_EU_CLIENT_ID, PLAID_EU_SECRET Plaid (EU) EU bank linking/sync
STRIPE_SECRET_KEY etc. Stripe Subscriptions (managed mode only)
INTERCOM_APP_ID, INTERCOM_IDENTITY_VERIFICATION_KEY Intercom In-app messaging (production)
SENTRY_DSN Sentry Error + performance monitoring (production, 25% sample)
DEV_WEBHOOKS_URL — Override webhook base URL in non-production

File storage (Active Storage)

Defaults to local disk. To use object storage, set ACTIVE_STORAGE_SERVICE and the matching credentials (config/storage.yml):

Variable Description
ACTIVE_STORAGE_SERVICE amazon (S3) or cloudflare (R2); unset = Disk
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION, S3_BUCKET Amazon S3 (S3_REGION defaults us-east-1)
CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_ACCESS_KEY_ID, CLOUDFLARE_SECRET_ACCESS_KEY, CLOUDFLARE_BUCKET Cloudflare R2

Important

If you use Disk storage (the default), the web and worker containers must share the storage volume (the Compose file mounts app-storage:/rails/storage on web). Exports generated by the worker need to be readable by the web process for download. libvips is required for image processing and is included in the image.

Sidekiq Web dashboard credentials

Variable Default Description
SIDEKIQ_WEB_USERNAME maybe Basic-auth user for /sidekiq (production)
SIDEKIQ_WEB_PASSWORD maybe Basic-auth password for /sidekiq (production)

Caution

The /sidekiq dashboard is protected by HTTP Basic auth that defaults to maybe / maybe. Anyone who reaches it can inspect and control your job queues. Set SIDEKIQ_WEB_USERNAME and SIDEKIQ_WEB_PASSWORD to strong values, or block /sidekiq at your reverse proxy.


4. Deployment & Scaling

Reference Docker Compose

The shipped compose.example.yml defines four services sharing a bridge network:

services:
  web:      # ghcr.io/maybe-finance/maybe:latest, ports 3000:3000, volume app-storage
  worker:   # same image, command: bundle exec sidekiq
  db:       # postgres:16, volume postgres-data, healthcheck pg_isready
  redis:    # redis:latest, volume redis-data, healthcheck redis-cli ping
volumes: [ app-storage, postgres-data, redis-data ]

web and worker depend on db and redis being healthy before they start. Both restart unless-stopped.

Production deployment checklist

  • [ ] Set a unique SECRET_KEY_BASE (openssl rand -hex 64).
  • [ ] Set a strong POSTGRES_PASSWORD.
  • [ ] Terminate TLS at a reverse proxy (Caddy, Traefik, nginx) and set RAILS_ASSUME_SSL / RAILS_FORCE_SSL appropriately.
  • [ ] Set strong SIDEKIQ_WEB_USERNAME / SIDEKIQ_WEB_PASSWORD (or block /sidekiq).
  • [ ] Configure SMTP if you need password resets / email confirmation / reports.
  • [ ] Decide on file storage (Disk vs S3/R2) and provision a backup for the DB volume.
  • [ ] Add SYNTH_API_KEY if investments or multi-currency are in use.
  • [ ] Add OPENAI_ACCESS_TOKEN (with spend limits) if AI is wanted.
  • [ ] Pin the image tag you've tested rather than tracking a moving latest.

Pinning and updating

image: ghcr.io/maybe-finance/maybe:stable   # latest tagged release
# image: ghcr.io/maybe-finance/maybe:latest # latest commit
docker compose pull
docker compose build
docker compose up --no-deps -d web worker

Warning

The entrypoint runs database preparation on start. Take a database backup before applying an update that may include migrations, especially since upstream is no longer publishing new releases and you may be building from source.

Scaling

  • Vertical: raise RAILS_MAX_THREADS to give Puma and Sidekiq more concurrency on a larger box.

  • Horizontal worker: run additional worker replicas; Sidekiq coordinates through Redis. Queues and weights are fixed in config/sidekiq.yml:

    concurrency: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %>
    queues:
      - [scheduled, 10]
      - [high_priority, 4]
      - [medium_priority, 2]
      - [low_priority, 1]
      - [default, 1]
    
  • For a single household, one web and one worker are typically plenty.


5. Operations

Health and status

Check How
App liveness GET /up returns 200 (boots cleanly) or 500
Job queues /sidekiq dashboard (Basic auth)
Container health docker compose ps (db/redis have healthchecks)
Component previews /design-system (Lookbook) for UI components

Background jobs

All asynchronous work runs as Sidekiq jobs (app/jobs/). The most important:

Job Queue Purpose
SyncJob high_priority Run an account/family sync
ImportJob / RevertImportJob high / medium Publish or revert a CSV import
AssistantResponseJob high_priority Generate an AI assistant reply
AutoCategorizeJob / AutoDetectMerchantsJob medium Auto-categorize / detect merchants
RuleJob medium_priority Apply a rule to matching transactions
ImportMarketDataJob scheduled Daily market-data import (prices, FX)
SecurityHealthCheckJob scheduled Detect bad security data
SyncCleanerJob scheduled Clean up stale syncs
FamilyDataExportJob default Build a family export ZIP
FamilyResetJob low_priority Delete a family's financial data (keep users)
DataCacheClearJob low_priority Purge exchange rates, prices, balances, holdings; re-sync
StripeEventHandlerJob default Process a Stripe webhook event
DestroyJob / UserPurgeJob low_priority Deferred record/user deletion

ApplicationJob retries on ActiveRecord::Deadlocked and discards on deserialization errors; the default queue is low_priority.

Scheduled (cron) jobs

Defined in config/schedule.yml and run by sidekiq-cron:

Job Schedule (cron) Purpose
ImportMarketDataJob 0 22 * * 1-5 Daily market-data import ~1h after US market close
SyncCleanerJob 0 * * * * Hourly cleanup of stale syncs
SecurityHealthCheckJob 0 2 * * 1-5 Security-data health checks on weekdays

Note

sidekiq-cron is configured with a 10-minute "catch-up" grace window (reschedule_grace_period = 600) so a cron tick that lands during a worker redeploy still fires. ImportMarketDataJob and SecurityHealthCheckJob no-op in development.

Backups and restore

Tip

Two complementary backups: (1) application-level via Settings → Profile → Export (a portable ZIP per family), and (2) infrastructure-level via the PostgreSQL postgres-data volume plus your Active Storage location.

A volume-level reset (destructive — wipes all data), per the hosting docs:

docker compose down
docker volume rm maybe_postgres-data
docker compose up
docker exec -it maybe-postgres-1 psql -U maybe -d maybe_production -c "SELECT 1;"

Cache management

Admins can clear computed/cached financial data from Settings → Self-Hosting → Clear cache, which enqueues DataCacheClearJob (drops exchange rates, security prices, balances, and holdings, then triggers a fresh sync).


6. Performance

  • YJIT is enabled after initialization (enable_yjit.rb) for a free Ruby-level speedup.
  • Caching: family- and entry-level cache keys are built from latest_sync_completed_at and max updated_at timestamps (Family#build_cache_key, entries_cache_version) so aggregations invalidate automatically when data changes. Sparkline and chart endpoints use HTTP conditional GET (stale? with ETag + last_modified) to return 304 Not Modified and skip expensive series computation.
  • Market-data batching: ImportMarketDataJob pre-fetches the prices and FX rates all families need once per day, so individual account syncs don't redo overlapping work (e.g. a common ticker like AAPL).
  • Profiling tools (development): Rack::MiniProfiler (with skip paths configured), Vernier/Stackprof, derailed_benchmarks, benchmark-ips.
  • Query hygiene: the conventions call for avoiding N+1s with includes/joins; API and web list endpoints eager-load associations and paginate with Pagy (overflow: :last_page).

Note

Pagination defaults: the web Transactions list defaults to 20 per page; the API defaults to 25 and caps at 100 per page.


7. Security

Maybe layers several mechanisms; review each before exposing an instance publicly.

Authentication

  • Web: session-based auth with has_secure_password (bcrypt). Sessions are stored as Session records keyed by a signed session_token cookie.
  • API: two methods (see Api::V1::BaseController):
    • OAuth2 via Doorkeeper — Authorization: Bearer <token>.
    • API keys — X-Api-Key: <key> header, tied to a user.
  • MFA: TOTP via ROTP (User#setup_mfa!/verify_otp?), issuer "Maybe Finance", with 8 single-use backup codes and a small verification drift window.

Password policy

Enforced on registration (RegistrationsController): minimum 8 characters, with at least one uppercase, one lowercase, one digit, and one special character.

API authorization, scopes, and rate limits

  • Scopes are hierarchical: read is satisfied by read or read_write; write requires read_write. Doorkeeper default_scopes :read, optional_scopes :read_write. Access tokens last 1 year; refresh tokens and PKCE are enabled; token and application secrets are hashed at rest (hash_token_secrets, hash_application_secrets).
  • Rate limiting is enforced two ways:
    • Application-level for API-key auth (ApiRateLimiter), emitting X-RateLimit-Limit/Remaining/Reset headers and 429 on exceed.
    • Rack::Attack (config/initializers/rack_attack.rb), enabled in production/staging:
Throttle / rule Limit
/oauth/token per IP 10 / minute
/api/* per access token self-hosted 10,000/hr, managed 100/hr
/api/* per IP self-hosted 20,000/hr, managed 200/hr
Blocklist (user agents sqlmap, nmap, nikto, masscan) Blocked (403)

Throttled requests get 429 with Retry-After: 60.

Warning

Rack::Attack throttling is only active in production/staging (Rails.env.production? || staging?). If you run the app in a different environment or front it directly without a proxy, add your own network-level rate limiting and IP filtering.

Data protection

  • Active Record encryption protects sensitive columns (e.g. stored provider API keys); keys derive deterministically from SECRET_KEY_BASE when not set explicitly.
  • Log filtering (filter_parameter_logging.rb) redacts parameters matching passw, email, secret, token, _key, crypt, salt, certificate, otp, ssn.
  • UUID primary keys everywhere (generator.rb) — no guessable sequential IDs.
  • CSRF protection is on for web controllers; it's deliberately skipped for the JSON API and Doorkeeper's OAuth endpoints (which use their own OAuth security), with documented rationale in doorkeeper_csrf_protection.rb.

Access control and impersonation

  • Roles: member, admin, super_admin (admin? = admin or super_admin). Admin-only actions include inviting/removing members, generating invite codes, starting exports, and clearing cache.
  • Impersonation (ImpersonationSession) is restricted to super_admin and requires the impersonated user's approval — a support feature, gated tightly.
  • Family-scoped access: API controllers verify resources belong to the authenticated user's family (ensure_current_family_access); the OAuth resource_owner_authenticator reuses the app's session logic.

Caution

Several admin/operator surfaces are powerful: the /sidekiq dashboard (weak default Basic-auth creds), super-admin impersonation, and FamilyResetJob / DataCacheClearJob (destructive). Lock down credentials, restrict who is super_admin, and put the app behind TLS + a trusted proxy.

Hardening checklist

  • [ ] Unique SECRET_KEY_BASE and POSTGRES_PASSWORD.
  • [ ] Strong SIDEKIQ_WEB_USERNAME/PASSWORD, or block /sidekiq at the proxy.
  • [ ] TLS terminated; RAILS_FORCE_SSL/RAILS_ASSUME_SSL set correctly.
  • [ ] Enable MFA on admin accounts.
  • [ ] Turn on Require invite for signup so a public URL can't be used to self-register.
  • [ ] Keep OPENAI_ACCESS_TOKEN and provider keys out of source control and set OpenAI spend limits.
  • [ ] Verify Rack::Attack is active in your environment (production) or add equivalent protection.

8. Developer Internals

Repository structure

Path Contents
app/controllers/ Web (Turbo) controllers; api/v1/ JSON controllers; settings/ namespace
app/models/ Domain models, concerns, POROs; provider/ integrations; rule/, demo/
app/components/ ViewComponents (DS/, UI/) for reusable UI
app/javascript/controllers/ Stimulus controllers (eager-loaded via importmap)
app/jobs/ Sidekiq job classes
app/mailers/ Email (password reset, email confirmation, invitations)
app/views/ ERB templates; api/v1/*.json.jbuilder for API responses
config/ Routes, initializers, schedule.yml, sidekiq.yml, currencies.yml, locales
db/migrate/ ~199 migrations; UUID PKs
lib/ Money value objects, rake tasks
test/ Minitest tests + fixtures, VCR cassettes, system tests

Front-end architecture

  • Hotwire-first: Turbo Frames/Streams drive SPA-like updates; controllers frequently respond_to with both html and turbo_stream. Native HTML elements (<dialog>, <details>) are preferred over JS widgets.
  • Stimulus controllers (eager-loaded from app/javascript/controllers/index.js via the importmap) handle interactivity; component-scoped controllers live with their ViewComponent.
  • Styling: Tailwind CSS v4 with a custom design system in app/assets/tailwind/maybe-design-system.css; functional tokens (text-primary, bg-container) are used rather than raw colors. Charts use D3 (time series, donut, Sankey). Lookbook previews components at /design-system.

API surface

JSON API under /api/v1 (responses rendered via Jbuilder):

Method(s) Path Notes
POST /api/v1/auth/signup, /auth/login, /auth/refresh Token-based auth
GET /api/v1/accounts List accounts (paginated)
GET/POST/PATCH/DELETE /api/v1/transactions Full CRUD with rich filters
GET /api/v1/usage API usage/quota
GET/POST/PATCH/DELETE /api/v1/chats (+ nested messages, messages/retry) AI chat (requires AI enabled)

The transactions endpoint filters by account(s), category/-ies, merchant(s), date range, amount range, tags, and type (income/expense), plus free-text search. Chat endpoints additionally require the user to have AI enabled and use read/ write scopes; see docs/api/chats.md in the repo for request/response shapes.

Note

Some /api/v1/test* routes exist only in the test environment for controller tests; they are not part of the production API.

Providers (integration layer)

app/models/provider/ wraps every external service behind a Provider::Registry, with concept mixins (LlmConcept, SecurityConcept, ExchangeRateConcept) so the app depends on capabilities, not vendors:

Provider Used for
Provider::Plaid / Provider::PlaidSandbox Bank linking & sync (US/EU)
Provider::Synth Exchange rates, security prices, usage reporting
Provider::Openai AI assistant (function/tool calling)
Provider::Stripe Subscriptions & billing webhooks (managed mode)
Provider::Github Fetch latest release notes for the changelog page

The AI assistant (Assistant) streams responses and supports tool calls that read the user's financial data; tools are wired through a FunctionToolCaller.

Data ingestion

Two paths, both worker-backed:

  1. Plaid sync — PlaidItem manages a connection; Sync tracks an operation; webhooks (/webhooks/plaid, /webhooks/plaid_eu) and SyncJob keep data fresh.
  2. CSV import — Import (with TransactionImport, TradeImport, MintImport subtypes) drives a multi-step flow: upload → configure/map (templates supported) → clean → confirm → publish, all via ImportJob; reversible via RevertImportJob. Imports enforce a maximum row count.

Money & multi-currency

lib/money and the Monetizable concern model amounts as value objects. config/currencies.yml defines 160+ currencies (fiat, crypto, precious metals) with symbols, precision, and formatting. A family needs a data provider (Synth) only when it holds trades or uses more than one currency (Family#requires_data_provider?).

Subscriptions / billing

Subscription mirrors Stripe statuses (trialing, active, past_due, canceled, etc.) with a 14-day trial. Self-hosted instances disable billing entirely (SubscriptionsController guards the feature when self_hosted?), so the subscription model is effectively managed-mode only.

Testing strategy

Per the repo's conventions (CLAUDE.md):

  • Minitest + fixtures only — never RSpec or factories. Fixtures are kept minimal (2–3 per model); edge cases are built inline.
  • VCR records external API interactions (Plaid, Stripe, OpenAI, Synth cassettes live under test/vcr_cassettes/).
  • System tests (Capybara + Selenium) cover critical flows, used sparingly.
  • Test boundaries: assert commands were called with correct params; assert query outputs; don't test other classes' implementation details.

Pre-PR CI workflow (also from CLAUDE.md): bin/rails test (and test:system when relevant), bin/rubocop -f github -a, bundle exec erb_lint, and bin/brakeman for static security analysis. JS/TS is linted/formatted with Biome (npm run lint / format).

Local development (contributors)

cd maybe
cp .env.local.example .env.local
bin/setup
bin/dev
# Optional demo data:
rake demo_data:default

bin/dev runs Rails, Sidekiq, and the Tailwind watcher together (Procfile.dev). A seeded login is [email removed] / password. Useful commands: bin/rails console, bin/rails db:prepare, bin/rails test test/models/account_test.rb:42.

Note

Required dev tooling matches the image: Ruby per .ruby-version (3.4.4) and PostgreSQL >9.3. libvips must be installed locally for Active Storage image processing.

Potential improvements

These are observations for anyone forking or maintaining the project; they are not defects in normal operation:

  • Rules are transaction-only. Rule#registry raises UnsupportedResourceTypeError for anything but transaction. Extending to other resource types (e.g. trades) would require new registry classes.
  • Rack::Attack only in production/staging. Non-production deployments get no built-in throttling; document or add protection for other environments.
  • Default Sidekiq Web credentials (maybe/maybe) are a foot-gun on public instances — consider failing closed when the defaults are unchanged.
  • Long-lived access tokens. OAuth access tokens default to a 1-year lifetime; tighten if your threat model warrants.
  • Unmaintained upstream. With v0.6.0 as the final release, security patches to dependencies (Rails and gems) are a fork's responsibility — track CVEs in the pinned gem set.

Contributing

The repository is AGPLv3 and open to forks. To stay compliant: keep the original license, state clearly that a fork is "not affiliated with or endorsed by Maybe Finance," and do not reuse the "Maybe" name or logo (both are trademarks of Maybe Finance, Inc.). Follow the existing conventions — minimize dependencies, fat models / skinny controllers, Hotwire-first, Minitest + fixtures — and run the full pre-PR CI workflow before submitting changes.


9. Integration Points

Integration Direction Mechanism
Plaid (US/EU) Inbound bank data Provider::Plaid, link tokens, webhooks /webhooks/plaid, /webhooks/plaid_eu
Synth Outbound API calls Exchange rates + security prices; key via SYNTH_API_KEY or settings
OpenAI Outbound API calls AI assistant via ruby-openai; key via OPENAI_ACCESS_TOKEN
Stripe Bidirectional Checkout + billing portal; webhook /webhooks/stripe → StripeEventHandlerJob (managed mode)
GitHub Outbound Release notes for the changelog page (octokit)
SMTP Outbound email Password resets, email confirmation, invitations, reports
S3 / Cloudflare R2 File storage Active Storage service backends
Sentry / Skylight / Logtail Outbound telemetry Error + performance monitoring (production)
Developer API Inbound /api/v1 with OAuth2 (Doorkeeper) + API keys

Note

Every external integration is optional and gated on its environment variables — the app boots and runs (manual accounts, CSV import, budgeting, reporting) with none of them configured. Add providers as you need their capabilities.