# Maybe — Administrator & Developer Guide

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`:

```ruby
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:

```yaml
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

```yaml
image: ghcr.io/maybe-finance/maybe:stable   # latest tagged release
# image: ghcr.io/maybe-finance/maybe:latest # latest commit
```

```bash
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`:

  ```yaml
  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:

```bash
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)

```bash
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.

---

_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. Unofficial; not affiliated with or reviewed by the project._
