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 inconfig/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 commit77b5469.
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
webandworkercontainers run the same image with different commands (./bin/rails servervs.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 abalance_typeof:cash,:investment, or:non_cash. Account lifecycle is an AASM state machine:active,draft,disabled,pending_deletion. - Entry is the polymorphic ledger line; its
entryableis 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
transactionresource 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 inapp/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 packagescurl,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_SHAis 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_BASEis security-critical. The samplecompose.example.ymlships 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:initand set all three variables. Provide all three or none — if any is blank, all three are regenerated fromSECRET_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
webandworkercontainers must share the storage volume (the Compose file mountsapp-storage:/rails/storageonweb). Exports generated by the worker need to be readable by the web process for download.libvipsis 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
/sidekiqdashboard is protected by HTTP Basic auth that defaults tomaybe/maybe. Anyone who reaches it can inspect and control your job queues. SetSIDEKIQ_WEB_USERNAMEandSIDEKIQ_WEB_PASSWORDto strong values, or block/sidekiqat 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_SSLappropriately. - [ ] 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_KEYif 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_THREADSto give Puma and Sidekiq more concurrency on a larger box. -
Horizontal worker: run additional
workerreplicas; Sidekiq coordinates through Redis. Queues and weights are fixed inconfig/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
weband oneworkerare 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.ImportMarketDataJobandSecurityHealthCheckJobno-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-datavolume 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_atand maxupdated_attimestamps (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 return304 Not Modifiedand skip expensive series computation. - Market-data batching:
ImportMarketDataJobpre-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 likeAAPL). - 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 asSessionrecords keyed by a signedsession_tokencookie. - API: two methods (see
Api::V1::BaseController):- OAuth2 via Doorkeeper —
Authorization: Bearer <token>. - API keys —
X-Api-Key: <key>header, tied to a user.
- OAuth2 via Doorkeeper —
- 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:
readis satisfied byreadorread_write;writerequiresread_write. Doorkeeperdefault_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), emittingX-RateLimit-Limit/Remaining/Resetheaders and429on exceed. Rack::Attack(config/initializers/rack_attack.rb), enabled in production/staging:
- Application-level for API-key auth (
| 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_BASEwhen not set explicitly. - Log filtering (
filter_parameter_logging.rb) redacts parameters matchingpassw,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 tosuper_adminand 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 OAuthresource_owner_authenticatorreuses the app's session logic.
Caution
Several admin/operator surfaces are powerful: the
/sidekiqdashboard (weak default Basic-auth creds), super-admin impersonation, andFamilyResetJob/DataCacheClearJob(destructive). Lock down credentials, restrict who issuper_admin, and put the app behind TLS + a trusted proxy.
Hardening checklist
- [ ] Unique
SECRET_KEY_BASEandPOSTGRES_PASSWORD. - [ ] Strong
SIDEKIQ_WEB_USERNAME/PASSWORD, or block/sidekiqat the proxy. - [ ] TLS terminated;
RAILS_FORCE_SSL/RAILS_ASSUME_SSLset 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_TOKENand 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_towith bothhtmlandturbo_stream. Native HTML elements (<dialog>,<details>) are preferred over JS widgets. - Stimulus controllers (eager-loaded from
app/javascript/controllers/index.jsvia 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:
- Plaid sync —
PlaidItemmanages a connection;Synctracks an operation; webhooks (/webhooks/plaid,/webhooks/plaid_eu) andSyncJobkeep data fresh. - CSV import —
Import(withTransactionImport,TradeImport,MintImportsubtypes) drives a multi-step flow: upload → configure/map (templates supported) → clean → confirm → publish, all viaImportJob; reversible viaRevertImportJob. 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.libvipsmust 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#registryraisesUnsupportedResourceTypeErrorfor anything buttransaction. 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.0as 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.