## 1. Project Overview & Quickstart (we-promise/sure)

## File: README.md

[](https://deepwiki.com/we-promise/sure)
[](https://oss.skylight.io/app/applications/s6PEZSKwcklL)
[](https://app.dosu.dev/a72bdcfd-15f5-4edc-bd85-ea0daa6c3adc/ask)
[](https://github.com/we-promise/sure/actions/workflows/pipelock.yml)





# Sure: The personal finance app for everyone

Get
involved: [Discord](https://discord.gg/36ZGBsxYEK) • [Website](https://sure.am) • [Issues](https://github.com/we-promise/sure/issues)

> [!IMPORTANT]
> This repository is a community fork of the now-abandoned Maybe Finance project. 
> Learn more in their [final release](https://github.com/maybe-finance/maybe/releases/tag/v0.6.0) doc.

## Backstory

The [Maybe Finance](https://github.com/maybe-finance/maybe) (archived/abandoned repo) team spent most of 2021–2022 building a full-featured personal finance and wealth management app. It even included an “Ask an Advisor” feature that connected users with a real CFP/CFA — all included with your subscription.

The business end of things didn't work out, and so they stopped developing the app in mid-2023.

After spending nearly $1 million on development (employees, contractors, data providers, infra, etc.), the team open-sourced the app. Their goal was to let users self-host it for free — and eventually launch a hosted version for a small fee.

They actually did launch that hosted version … briefly.

That also didn’t work out — at least not as a sustainable B2C business — so now here we are: hosting a community-maintained fork to keep the codebase alive and see where this can go next.

Join us!

## Hosting Sure

Sure is a fully working personal finance app that can be [self hosted with Docker](docs/hosting/docker.md).

## Forking and Attribution

This repo is a community fork of the archived Maybe Finance repo.
You’re free to fork it under the AGPLv3 license — but we’d love it if you stuck around and contributed here instead.

To stay compliant and avoid trademark issues:

- Be sure to include the original [AGPLv3 license](https://github.com/maybe-finance/maybe/blob/main/LICENSE) and clearly state in your README that your fork is based on Maybe Finance but is **not affiliated with or endorsed by** Maybe Finance Inc.
- "Maybe" is a trademark of Maybe Finance Inc. and therefore, use of it is NOT allowed in forked repositories (or the logo)

## Performance Issues

With data-heavy apps, inevitably, there are performance issues. We've set up a public dashboard showing the problematic requests seen on the demo site, along with the stacktraces to help debug them.

[https://www.skylight.io/app/applications/s6PEZSKwcklL/recent/6h/endpoints](https://oss.skylight.io/app/applications/s6PEZSKwcklL/recent/6h/endpoints)

Any contributions that help improve performance are very much welcome.

## Local Development Setup

**If you are trying to _self-host_ the app, [read this guide to get started](docs/hosting/docker.md).**

The instructions below are for developers to get started with contributing to the app.

### 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
cd sure
cp .env.local.example .env.local
bin/setup
bin/dev

# Optionally, load demo data
rake demo_data:default
```

Visit http://localhost:3000 to view the app.

If you loaded the optional demo data, log in with these credentials:

- Email: `user@example.com`
- Password: `Password1!`

For further instructions, see guides below.

### Setup Guides

- [Mac dev setup](https://github.com/we-promise/sure/wiki/Mac-Dev-Setup-Guide)
- [Linux dev setup](https://github.com/we-promise/sure/wiki/Linux-Dev-Setup-Guide)
- [Windows dev setup](https://github.com/we-promise/sure/wiki/Windows-Dev-Setup-Guide)
- Dev containers - visit [this guide](https://code.visualstudio.com/docs/devcontainers/containers)

### One-click Install

[](https://www.pikapods.com/pods?run=sure)

[](https://railway.com/deploy/sure?referralCode=CW_fPQ)

### Managed OpenClaw for Sure Finances

[](https://kilocode.pxf.io/repo-readme)


## License and Trademarks

Maybe and Sure are both distributed under
an [AGPLv3 license](https://github.com/we-promise/sure/blob/main/LICENSE).
- "Maybe" is a trademark of Maybe Finance, Inc.
- "Sure" is not, and refers to this community fork.

---

## File: charts/sure/README.md

# Sure Helm Chart

Official Helm chart for deploying the Sure Rails application on Kubernetes. It supports web (Rails) and worker (Sidekiq) workloads, optional in-cluster PostgreSQL (CloudNativePG) and Redis subcharts for turnkey self-hosting, and production-grade features like pre-upgrade migrations, pod security contexts, HPAs, and optional ServiceMonitor.

## Features

- Web (Rails) Deployment + Service and optional Ingress
- Worker (Sidekiq) Deployment
- Optional Helm-hook Job for db:migrate, or initContainer migration strategy
- Optional post-install/upgrade SimpleFin encryption backfill Job (idempotent; dry-run by default)
- Optional CronJobs for custom tasks
- Optional subcharts
  - CloudNativePG (operator) + Cluster CR for PostgreSQL with HA support
  - OT-CONTAINER-KIT redis-operator for Redis HA (replication by default, optional Sentinel)
- Optional Pipelock AI agent security proxy (forward proxy + MCP reverse proxy with DLP, prompt injection, and tool poisoning detection)
- Security best practices: runAsNonRoot, readOnlyRootFilesystem, optional existingSecret, no hardcoded secrets
- Scalability
  - Replicas (web/worker), resources, topology spread constraints
  - Optional HPAs for web/worker
  - Affinity, nodeSelector, tolerations

## Requirements

- Kubernetes >= 1.25
- Helm >= 3.10
- For subcharts: add repositories first
  ```sh
  helm repo add cloudnative-pg https://cloudnative-pg.github.io/charts
  helm repo add ot-helm https://ot-container-kit.github.io/helm-charts
  helm repo update
  ```

## Quickstart (turnkey self-hosting)

This installs CNPG operator + a Postgres cluster and Redis managed by the OT redis-operator (replication mode by default). It also creates an app Secret if you provide values under `rails.secret.values` (recommended for quickstart only; prefer an existing Secret or External Secrets in production).

Important: For production stability, use immutable image tags (for example, set `image.tag=v1.2.3`) instead of `latest`.

```sh
# Namespace
kubectl create ns sure || true

# Install chart (example: provide SECRET_KEY_BASE and pin an immutable image tag)
helm upgrade --install sure charts/sure \
  -n sure \
  --set image.tag=v1.2.3 \
  --set rails.secret.enabled=true \
  --set rails.secret.values.SECRET_KEY_BASE=$(openssl rand -hex 32)
```

Expose the app via an Ingress (see values) or `kubectl port-forward svc/sure 8080:80 -n sure`.

## Using external Postgres/Redis

Disable the bundled CNPG/Redis resources and set URLs explicitly.

```yaml
cnpg:
  enabled: false

redisOperator:
  managed:
    enabled: false

redisSimple:
  enabled: false

rails:
  extraEnv:
    DATABASE_URL: postgresql://user:pass@db.example.com:5432/sure
    REDIS_URL: redis://:pass@redis.example.com:6379/0
```

## Installation profiles

### Deployment modes

| Mode                         | Description                               | Key values                                                                 |
|------------------------------|-------------------------------------------|----------------------------------------------------------------------------|
| Simple single-node           | All-in-one, minimal HA                    | `cnpg.cluster.instances=1`, `redisOperator.mode=replication`               |
| HA self-hosted (replication) | CNPG + RedisReplication spread over nodes | `cnpg.cluster.instances=3`, `redisOperator.mode=replication`               |
| HA self-hosted (Sentinel)    | Replication + Sentinel failover layer     | `redisOperator.mode=sentinel`, `redisOperator.sentinel.enabled=true`       |
| External DB/Redis            | Use managed Postgres/Redis                | `cnpg.enabled=false`, `redisOperator.managed.enabled=false`, set URLs envs |

Below are example value stubs you can start from, depending on whether you want a simple single-node setup or a more HA-oriented k3s cluster.

### Simple single-node / low-resource profile

```yaml
image:
  tag: "v1.0.0"          # pin a specific version in production
  pullPolicy: IfNotPresent

rails:
  existingSecret: sure-secrets
  encryptionEnv:
    enabled: true
  settings:
    SELF_HOSTED: "true"

cnpg:
  enabled: true
  cluster:
    enabled: true
    name: sure-db
    instances: 1
    storage:
      size: 8Gi
      storageClassName: longhorn

redisOperator:
  enabled: true
  managed:
    enabled: true
  mode: replication
  sentinel:
    enabled: false
  replicas: 3
  persistence:
    enabled: true
    className: longhorn
    size: 8Gi

migrations:
  strategy: job

simplefin:
  encryption:
    enabled: false         # enable + backfill later once you're happy
    backfill:
      enabled: true
      dryRun: true
```

### HA k3s profile (example)

```yaml
cnpg:
  enabled: true
  cluster:
    enabled: true
    name: sure-db
    instances: 3
    storage:
      size: 20Gi
      storageClassName: longhorn
    # Optional: enable CNPG volume snapshot backups (requires a VolumeSnapshotClass)
    backup:
      method: volumeSnapshot
      volumeSnapshot:
        className: longhorn
    # Synchronous replication for stronger durability
    minSyncReplicas: 1
    maxSyncReplicas: 2
    # Spread CNPG instances across nodes (adjust selectors for your cluster)
    topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: ScheduleAnyway
        labelSelector:
          matchLabels:
            cnpg.io/cluster: sure-db

redisOperator:
  enabled: true
  managed:
    enabled: true
  mode: replication
  sentinel:
    enabled: false
  replicas: 3
  persistence:
    enabled: true
    className: longhorn
    size: 8Gi

migrations:
  strategy: job
  initContainer:
    enabled: true   # optional safety net on pod restarts (only migrates when pending)

simplefin:
  encryption:
    enabled: true
    backfill:
      enabled: true
      dryRun: false
```

## CloudNativePG notes

- The chart configures credentials via `spec.bootstrap.initdb.secret` rather than `managed.roles`. The operator expects the referenced Secret to contain `username` and `password` keys (configurable via values).
- This chart generates the application DB Secret when `cnpg.cluster.secret.enabled=true` using the keys defined at `cnpg.cluster.secret.usernameKey` (default `username`) and `cnpg.cluster.secret.passwordKey` (default `password`). If you use an existing Secret (`cnpg.cluster.existingSecret`), ensure it contains these keys. The Cluster CR references the Secret by name and maps the keys accordingly.
- If the CNPG operator is already installed cluster‑wide, you may set `cnpg.enabled=false` and keep `cnpg.cluster.enabled=true`. The chart will still render the `Cluster` CR and compute the in‑cluster `DATABASE_URL`.
- For backups, CNPG requires `spec.backup.method` to be explicit (for example `volumeSnapshot` or `barmanObjectStore`). This chart will infer `method: volumeSnapshot` if a `backup.volumeSnapshot` block is present.
  - For snapshot backups, `backup.volumeSnapshot.className` must be set (the chart will fail the render if it is missing).
  - The CNPG `spec.backup` schema does not support keys like `ttl` or `volumeSnapshot.enabled`; this chart strips those keys to avoid CRD warnings.
  - Unknown `backup.method` values are passed through and left for CNPG to validate.

Example (barman-cloud plugin for WAL archiving + snapshot backups):

```yaml
cnpg:
  cluster:
    plugins:
      - name: barman-cloud.cloudnative-pg.io
        isWALArchiver: true
        parameters:
          barmanObjectName: minio-backups  # references an ObjectStore CR
    backup:
      method: volumeSnapshot
      volumeSnapshot:
        className: longhorn
```

Additional default hardening:

- `DATABASE_URL` includes `?sslmode=prefer`.
- Init migrations run `db:create || true` before `db:migrate` for first‑boot convenience.

## Redis URL and authentication

- When the OT redis-operator is used via this chart (see `redisOperator.managed.enabled=true`), `REDIS_URL` resolves to the operator's stable master service. In shell contexts, this can be expressed as:
  - `redis://default:$(REDIS_PASSWORD)@<name>-redis-master.<namespace>.svc.cluster.local:6379/0` (where `<name>` defaults to `<fullname>-redis` but is overrideable via `redisOperator.name`)
  For Kubernetes manifests, do not inline shell expansion. Either let this chart construct `REDIS_URL` for you automatically (recommended), or use a literal form with a placeholder password, e.g.:
  - `redis://default:@<name>-redis-master.<namespace>.svc.cluster.local:6379/0`
- The `default` username is required with Redis 6+ ACLs. If you explicitly set `REDIS_URL` under `rails.extraEnv`, your value takes precedence.
- The Redis password is taken from `sure.redisSecretName` (typically your app Secret, e.g. `sure-secrets`) using the key returned by `sure.redisPasswordKey` (default `redis-password`).
- If you prefer a simple (non‑HA) in‑cluster Redis, disable the operator-managed Redis (`redisOperator.managed.enabled=false`) and enable `redisSimple.enabled`. The chart will deploy a single Redis Pod + Service and wire `REDIS_URL` accordingly. Provide a password via `redisSimple.auth.existingSecret` (recommended) or rely on your app secret mapping.

### Using the OT redis-operator (Sentinel)

This chart can optionally install the OT-CONTAINER-KIT Redis Operator and/or render a `RedisSentinel` CR to manage Redis HA with Sentinel. This approach avoids templating pitfalls and provides stable failover.

Quickstart example (Sentinel, 3 replicas, Longhorn storage, reuse `sure-secrets` password):

```yaml
redisOperator:
  enabled: true              # install operator subchart (or leave false if already installed cluster-wide)
  operator:
    resources:               # optional: keep the operator light on small k3s nodes
      requests:
        cpu: 50m
        memory: 128Mi
      limits:
        cpu: 100m
        memory: 256Mi
  managed:
    enabled: true            # render Redis CRs for in-cluster Redis
  mode: sentinel             # enables RedisSentinel CR in addition to RedisReplication
  sentinel:
    enabled: true            # must be true when mode=sentinel
    masterGroupName: mymaster
  name: ""                   # defaults to <fullname>-redis
  replicas: 3
  auth:
    existingSecret: sure-secrets
    passwordKey: redis-password
  persistence:
    className: longhorn
    size: 8Gi
```

Notes:
- When `redisOperator.mode=sentinel` and `redisOperator.sentinel.enabled=true`, the chart automatically configures Sidekiq to use Redis Sentinel for high availability.
- The application receives `REDIS_SENTINEL_HOSTS` (comma-separated list of Sentinel endpoints) and `REDIS_SENTINEL_MASTER` (master group name) environment variables instead of `REDIS_URL`.
- Sidekiq will connect to Sentinel nodes for automatic master discovery and failover support.
- Both the Redis master and Sentinel nodes use the same password from `REDIS_PASSWORD` (via `redisOperator.auth.existingSecret`).
- Sentinel authentication uses username "default" by default (configurable via `REDIS_SENTINEL_USERNAME`).
- The operator master service is `<name>-redis-master.<ns>.svc.cluster.local:6379`.
- The CR references your existing password secret via `kubernetesConfig.redisSecret { name, key }`.
- Provider precedence for auto-wiring is: explicit `rails.extraEnv.REDIS_URL` → `redisOperator.managed` (with Sentinel if configured) → `redisSimple`.
- Only one in-cluster Redis provider should be enabled at a time to avoid ambiguity.

### HA scheduling and topology spreading

For resilient multi-node clusters, enforce one pod per node for critical components. Use `topologySpreadConstraints` with `maxSkew: 1` and `whenUnsatisfiable: DoNotSchedule`. Keep selectors precise to avoid matching other apps.

Examples:

```yaml
cnpg:
  cluster:
    instances: 3
    minSyncReplicas: 1
    maxSyncReplicas: 2
    topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels:
            cnpg.io/cluster: sure-db

redisOperator:
  managed:
    enabled: true
    replicas: 3
    topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels:
            app.kubernetes.io/instance: sure  # verify labels on your cluster
```

Security note on label selectors:
- Choose selectors that uniquely match the intended pods to avoid cross-app interference. Good candidates are:
  - CNPG: `cnpg.io/cluster: <cluster-name>` (CNPG labels its pods)
  - RedisReplication: `app.kubernetes.io/instance: <release-name>` or `app.kubernetes.io/name: <cr-name>`

#### Rolling update strategy

When using topology spread constraints with `whenUnsatisfiable: DoNotSchedule`, you must configure the Kubernetes rolling update strategy to prevent deployment deadlocks.

The chart now makes the rolling update strategy configurable for web and worker deployments. The defaults have been changed from Kubernetes defaults (`maxUnavailable=0`, `maxSurge=25%`) to:

```yaml
web:
  strategy:
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 0

worker:
  strategy:
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 0
```

**Why these defaults?**

With `maxSurge=0`, Kubernetes will terminate an old pod before creating a new one. This ensures that when all nodes are occupied (due to strict topology spreading), there is always space for the new pod to be scheduled.

If you use `maxSurge > 0` with `DoNotSchedule` topology constraints and all nodes are occupied, Kubernetes cannot create the new pod (no space available) and cannot terminate the old pod (new pod must be ready first), resulting in a deployment deadlock.

**Configuration examples:**

For faster rollouts when not using strict topology constraints:

```yaml
web:
  strategy:
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1

worker:
  strategy:
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
```

For HA setups with topology spreading:

```yaml
web:
  replicas: 3
  strategy:
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 0
  topologySpreadConstraints:
    - maxSkew: 1
      topologyKey: kubernetes.io/hostname
      whenUnsatisfiable: DoNotSchedule
      labelSelector:
        matchLabels:
          app.kubernetes.io/name: sure
          app.kubernetes.io/component: web
```

**Warning:** Using `maxSurge > 0` with `whenUnsatisfiable: DoNotSchedule` can cause deployment deadlocks when all nodes are occupied. If you need faster rollouts, either:
- Use `whenUnsatisfiable: ScheduleAnyway` instead of `DoNotSchedule`
- Ensure you have spare capacity on your nodes
- Keep `maxSurge: 0` and accept slower rollouts

Compatibility:
- CloudNativePG v1.27.1 supports `minSyncReplicas`/`maxSyncReplicas` and standard k8s scheduling fields under `spec`.
- OT redis-operator v0.21.0 supports scheduling under `spec.kubernetesConfig`.

Testing and verification:

```bash
# Dry-run render with your values
helm template sure charts/sure -n sure -f ha-values.yaml --debug > rendered.yaml

# Install/upgrade in a test namespace
kubectl create ns sure-test || true
helm upgrade --install sure charts/sure -n sure-test -f ha-values.yaml --wait

# Verify CRs include your scheduling config
kubectl get cluster.postgresql.cnpg.io sure-db -n sure-test -o yaml \
  | yq '.spec | {instances, minSyncReplicas, maxSyncReplicas, nodeSelector, affinity, tolerations, topologySpreadConstraints}'

# Default RedisReplication CR name is <fullname>-redis (e.g., sure-redis) unless overridden by redisOperator.name
kubectl get redisreplication sure-redis -n sure-test -o yaml \
  | yq '.spec.kubernetesConfig | {nodeSelector, affinity, tolerations, topologySpreadConstraints}'

# After upgrade, trigger a gentle reschedule to apply spreads
# CNPG: delete one pod at a time or perform a switchover
kubectl delete pod -n sure-test -l cnpg.io/cluster=sure-db --wait=false --field-selector=status.phase=Running

# RedisReplication: delete one replica pod to let the operator recreate it under new constraints
kubectl delete pod -n sure-test -l app.kubernetes.io/component=redis --wait=false

# Confirm distribution across nodes
kubectl get pods -n sure-test -o wide
```

## Example app Secret (sure-secrets)

You will typically manage secrets via an external mechanism (External Secrets, Sealed Secrets, etc.), but for reference, below is an example `Secret` that provides the keys this chart expects by default:

```yaml
apiVersion: v1
kind: Secret
metadata:
  name: sure-secrets
type: Opaque
stringData:
  # Rails secrets
  SECRET_KEY_BASE: "__SET_SECRET__"

  # Active Record Encryption keys (optional but recommended when using encryption features)
  ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: "__SET_SECRET__"
  ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: "__SET_SECRET__"
  ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: "__SET_SECRET__"

  # Redis password used by operator-managed or simple Redis
  redis-password: "__SET_SECRET__"

  # Optional: CNPG bootstrap user/password if you are not letting the chart generate them
  # username: "sure"
  # password: "__SET_SECRET__"
```

Note: These are non-sensitive placeholder values. Do not commit real secrets to version control. Prefer External Secrets, Sealed Secrets, or your platform's secret manager to source these at runtime.

### Linting Helm templates and YAML

Helm template files under `charts/**/templates/**` contain template delimiters like `{{- ... }}` that raw YAML linters will flag as invalid. To avoid false positives in CI:

- Use Helm's linter for charts:
  - `helm lint charts/sure`
- Configure your YAML linter (e.g., yamllint) to ignore Helm template directories (exclude `charts/**/templates/**`), or use a Helm-aware plugin that preprocesses templates before linting.

You can then point the chart at this Secret via:

```yaml
rails:
  existingSecret: sure-secrets

redisOperator:
  managed:
    enabled: true
  auth:
    existingSecret: sure-secrets
    passwordKey: redis-password

cnpg:
  cluster:
    existingSecret: sure-secrets   # if you are reusing the same Secret for DB creds
    secret:
      enabled: false               # do not generate a second Secret when using existingSecret
```

Environment variable ordering for shells:

- The chart declares `DB_PASSWORD` before `DATABASE_URL` and `REDIS_PASSWORD` before `REDIS_URL` in all workloads so that shell expansion with `$(...)` works reliably.

## Migrations

By default, this chart uses a **Helm hook Job** to prepare the database on **post-install/upgrade** using Rails' `db:prepare`, which will create the database (if needed) and apply migrations in one step. The Job waits for the database to be reachable via `pg_isready` before connecting.

Execution flow:

1. CNPG Cluster (if enabled) and other resources are created.
2. `sure-migrate` Job (post-install/post-upgrade hook) waits for the RW service to accept connections.
3. `db:prepare` runs; safe and idempotent across fresh installs and upgrades.
4. Optional data backfills (like SimpleFin encryption) run in their own post hooks.

To use the initContainer strategy instead (or in addition as a safety net):

```yaml
migrations:
  strategy: initContainer
  initContainer:
    enabled: true
```

## SimpleFin encryption backfill

- SimpleFin encryption is optional. If you enable it, you must provide Active Record Encryption keys.
- The backfill Job runs a safe, idempotent Rake task to encrypt existing `access_url` values.

```yaml
simplefin:
  encryption:
    enabled: true
    backfill:
      enabled: true
      dryRun: true  # set false to actually write changes

rails:
  # Provide encryption keys via an existing secret or below values (for testing only)
  existingSecret: my-app-secret
  # or
  secret:
    enabled: true
    values:
      ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: "..."
      ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: "..."
      ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: "..."
```

## Ingress

```yaml
ingress:
  enabled: true
  className: "nginx"
  hosts:
    - host: finance.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - hosts: [finance.example.com]
      secretName: finance-tls
```

## Boot-required secrets

The Rails initializer for Active Record Encryption loads on boot. To prevent boot crashes, ensure the following environment variables are present for ALL workloads (web, worker, migrate job/initContainer, CronJobs, and the SimpleFin backfill job):

- `SECRET_KEY_BASE`
- `ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY`
- `ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY`
- `ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT`

This chart wires these from your app Secret using `secretKeyRef`. Provide them via `rails.existingSecret` (recommended) or `rails.secret.values` (for testing only).

The injection of the three Active Record Encryption env vars can be toggled via:

```yaml
rails:
  encryptionEnv:
    enabled: true  # set to false to skip injecting the three AR encryption env vars
```

Note: In self-hosted mode, if these env vars are not provided, they will be automatically generated from `SECRET_KEY_BASE`. In managed mode, these env vars must be explicitly provided via environment variables or Rails credentials.

## Advanced environment variable injection

For simple string key/value envs, continue to use `rails.extraEnv` and the per-workload `web.extraEnv` / `worker.extraEnv` maps.

When you need `valueFrom` (e.g., Secret/ConfigMap references) or full EnvVar objects, use the new arrays:

```yaml
rails:
  extraEnvVars:
    - name: SOME_FROM_SECRET
      valueFrom:
        secretKeyRef:
          name: my-secret
          key: some-key
  extraEnvFrom:
    - secretRef:
        name: another-secret
```

These are injected into web, worker, migrate job/initContainer, CronJobs, and the SimpleFin backfill job in addition to the simple maps.

## Writable filesystem and /tmp

Rails and Sidekiq may require writes to `/tmp` during boot. The chart now defaults to:

```yaml
securityContext:
  readOnlyRootFilesystem: false
```

If you choose to enforce a read-only root filesystem, you can mount an ephemeral `/tmp` via:

```yaml
writableTmp:
  enabled: true
```

This will add an `emptyDir` volume mounted at `/tmp` for the web and worker pods.

## Local images on k3s/k3d/kind (development workflow)

When using locally built images on single-node k3s/k3d/kind clusters:

- Consider forcing a never-pull policy during development:
  ```yaml
  image:
    pullPolicy: Never
  ```
- Load your local image into the cluster runtime:
  - k3s (containerd):
    ```bash
    # Export your image to a tar (e.g., from Docker or podman)
    docker save ghcr.io/we-promise/sure:dev -o sure-dev.tar
    # Import into each node's containerd
    sudo ctr -n k8s.io images import sure-dev.tar
    ```
  - k3d:
    ```bash
    k3d image import ghcr.io/we-promise/sure:dev -c <your-cluster-name>
    ```
  - kind:
    ```bash
    kind load docker-image ghcr.io/we-promise/sure:dev --name <your-cluster-name>
    ```
- Multi-node clusters require loading the image into every node or pushing to a registry that all nodes can reach.

## HPAs

```yaml
hpa:
  web:
    enabled: true
    minReplicas: 2
    maxReplicas: 10
    targetCPUUtilizationPercentage: 70

  worker:
    enabled: true
    minReplicas: 2
    maxReplicas: 10
    targetCPUUtilizationPercentage: 70
```

## Pipelock (AI agent security proxy)

[Pipelock](https://github.com/luckyPipewrench/pipelock) is an optional security proxy that scans AI agent traffic for secret exfiltration, prompt injection, tool poisoning, and SSRF. It runs as a separate Deployment with two listeners:

- **Forward proxy** (port 8888): Scans outbound HTTPS from Faraday-based AI clients. Auto-injected via `HTTPS_PROXY` env vars when enabled.
- **MCP reverse proxy** (port 8889): Scans inbound MCP traffic from external AI assistants.

Recent Pipelock releases add default-on flight recorder receipts, safe-by-default receipt verification, MCP `defer` authorization, request-policy scoring, request-body prompt-injection blocking, SPIFFE-strict inbound mediation envelopes, scanner attribution on MCP block receipts, trusted domain allowlisting, MCP tool redirect profiles, learn-and-lock behavioural contracts, the wedge-detection health watchdog, `pipelock explain`, `pipelock keys status`, `pipelock support bundle`, verified `pipelock update`, and `pipelock doctor` checks for inert exemptions. Browser Shield, process sandboxing, request policy, redaction, and attack assessment are available via `extraConfig` and CLI. See the [Pipelock changelog](https://github.com/luckyPipewrench/pipelock/releases) for details.

### Enabling Pipelock

```yaml
pipelock:
  enabled: true
  image:
    tag: "2.8.0"
  mode: balanced   # strict, balanced, or audit
```

### Trusted domains

In Kubernetes, services often have public DNS records that resolve to private IPs. Without `trustedDomains`, the SSRF scanner blocks this legitimate traffic. Add trusted domains to allow them through:

```yaml
pipelock:
  trustedDomains:
    - "api.internal.example.com"
    - "*.corp.example.com"
```

### MCP tool redirect profiles

Redirect profiles route matched MCP tool calls to an audited handler program instead of blocking. The handler returns a synthetic MCP response, keeping the agent's flow intact while enforcing policy:

```yaml
pipelock:
  mcpToolPolicy:
    enabled: true
    action: redirect      # or use per-rule action overrides
    rules:
      - name: redirect-fetch
        toolPattern: "^(fetch|web_fetch)$"
        action: redirect
        redirectProfile: safe-fetch
    redirectProfiles:
      safe-fetch:
        exec: ["/pipelock", "internal-redirect", "fetch-proxy"]
        reason: "Route fetch calls through audited proxy"
```

### Request body scanning (pipelock 2.5+)

Pipelock 2.5 added prompt-injection detection on outbound request bodies (JSON, form-encoded, raw text, WebSocket frames). When enabled, findings hard-block non-provider destinations even when `action: warn`; trusted provider hosts (OpenAI, Anthropic, etc.) remain exempt through the response-scanning exemption list.

```yaml
pipelock:
  requestBodyScanning:
    enabled: true
    action: warn          # warn or block
    maxBodyBytes: 5242880 # 5 MB; fail-closed above this
    scanHeaders: true
    headerMode: sensitive # "sensitive" or "all"
```

Enabled by default with `action: warn`, matching Pipelock's balanced preset. Review findings in logs before flipping to `action: block`.

### Health watchdog

The wedge-detection watchdog returns 503 on `/health` when a subsystem heartbeat (proxy hot path, MCP listener, rules-engine reload watcher) goes stale. Enabled by default in pipelock; the chart exposes the controls so operators can opt into per-subsystem detail in the health payload:

```yaml
pipelock:
  healthWatchdog:
    enabled: true
    intervalSeconds: 2
    exposeSubsystems: true  # adds per-subsystem boolean map to /health
```

### Signed action receipts

The flight recorder writes hash-chained, Ed25519-signed action receipts. The chart renders the `flight_recorder` config, but recording is inert until you mount a writable evidence directory and the signing key named by `signingKeyPath`.

Generate the receipt key once:

```bash
# --out must be an absolute path; "$PWD/..." writes the key into the current directory.
pipelock signing key generate --purpose receipt-signing --out "$PWD/flight-recorder-signing.key" --id sure-k8s
kubectl create secret generic sure-pipelock-receipts \
  --namespace sure \
  --from-file=flight-recorder-signing.key=./flight-recorder-signing.key
```

If you do not have the `pipelock` binary installed, generate the key with the image instead: `docker run --rm -v "$PWD:/out" ghcr.io/luckypipewrench/pipelock:2.8.0 signing key generate --purpose receipt-signing --out /out/flight-recorder-signing.key --id sure-k8s`.

Then mount persistent evidence storage and the key:

```yaml
pipelock:
  flightRecorder:
    enabled: true
    dir: /var/lib/pipelock/evidence
    signingKeyPath: /run/secrets/pipelock/flight-recorder-signing.key
    requireReceipts: false
    redact: true
  extraVolumes:
    - name: pipelock-evidence
      persistentVolumeClaim:
        claimName: sure-pipelock-evidence
    - name: pipelock-receipt-key
      secret:
        secretName: sure-pipelock-receipts
        # 0440 (group-read), not 0400: the chart runs Pipelock as uid 1000 with
        # fsGroup 1000, so the secret file is owned root:1000. A 0400 file would
        # be unreadable by the non-root process and Pipelock would crash on
        # startup with a key-load error. 0440 lets the fsGroup read it.
        defaultMode: 0440
  extraVolumeMounts:
    - name: pipelock-evidence
      mountPath: /var/lib/pipelock/evidence
    - name: pipelock-receipt-key
      # Do not mount this under /etc/pipelock; the chart already mounts the
      # Pipelock ConfigMap there.
      mountPath: /run/secrets/pipelock
      readOnly: true
```

Only set `requireReceipts: true` after confirming receipts are being written. With it enabled, allow-path traffic fails closed if Pipelock cannot sign or persist the receipt.

### Validating your config

Pipelock includes CLI tools for config validation and deployment diagnostics:

```bash
# Create and run a broader assessment workspace
pipelock assess init --config pipelock.yaml

# Score your config's security posture (0-100)
pipelock audit score --config pipelock.yaml

# Report whether configured protections are actually enforceable
pipelock doctor

# Explain a block and the exact config knob that scanner reads
pipelock explain --config pipelock.yaml "https://example.com/path"
```

### Exposing MCP to external AI assistants

When running in Kubernetes, external AI agents need network access to the MCP reverse proxy port. Enable the Pipelock Ingress:

```yaml
pipelock:
  enabled: true
  ingress:
    enabled: true
    className: nginx
    annotations:
      cert-manager.io/cluster-issuer: letsencrypt
    hosts:
      - host: pipelock.example.com
        paths:
          - path: /
            pathType: Prefix
    tls:
      - hosts: [pipelock.example.com]
        secretName: pipelock-tls
```

Security: The Ingress routes to port `mcp` (8889). Ensure `MCP_API_TOKEN` is set so the MCP endpoint requires authentication. The Ingress itself does not add auth.

### Metrics (Prometheus)

Pipelock exposes `/metrics` on the forward proxy port. Enable scraping with a ServiceMonitor:

```yaml
pipelock:
  serviceMonitor:
    enabled: true
    interval: 30s
    portName: proxy   # matches Service port name for 8888
    additionalLabels:
      release: prometheus   # match your Prometheus Operator selector
```

### PodDisruptionBudget

Protect Pipelock from node drains:

```yaml
pipelock:
  pdb:
    enabled: true
    maxUnavailable: 1   # safe for single-replica; use minAvailable when replicas > 1
```

Note: Setting `minAvailable` with `replicas=1` blocks eviction entirely. Use `maxUnavailable` for single-replica deployments.

### Structured logging

```yaml
pipelock:
  logging:
    format: json        # json or text
    output: stdout
    includeAllowed: false
    includeBlocked: true
```

### Extra config (escape hatch)

For Pipelock config sections not covered by structured values (session profiling, data budgets, kill switch, sandbox, reverse proxy, adaptive enforcement, request policy, redaction, etc.), use `extraConfig`:

```yaml
pipelock:
  extraConfig:
    session_profiling:
      enabled: true
      max_sessions: 1000
    adaptive_enforcement:
      enabled: true
      exempt_domains:
        - "*.example.com"
    kill_switch:
      api_listen: ":9090"    # dedicated port for kill switch API
```

These are appended verbatim to `pipelock.yaml`. Do not duplicate keys already rendered by the chart.

### Requiring Pipelock for external assistants

To enforce that Pipelock is enabled whenever the external AI assistant feature is active:

```yaml
pipelock:
  requireForExternalAssistant: true
```

This causes `helm template` / `helm install` to fail if `rails.externalAssistant.enabled=true` and `pipelock.enabled=false`. Note: this only guards the `externalAssistant` path. Direct MCP access via `MCP_API_TOKEN` is configured through env vars and not detectable from Helm values.

## Security Notes

- Never commit secrets in `values.yaml`. Use `rails.existingSecret` or a tool like Sealed Secrets.
- The chart defaults to `runAsNonRoot`, `fsGroup=1000`, and drops all capabilities.
- For production, set resource requests/limits and enable HPAs.

## Values overview

Tip: For production stability, prefer immutable image tags. Set `image.tag` to a specific release (e.g., `v1.2.3`) rather than `latest`.

See `values.yaml` for the complete configuration surface, including:

- `image.*`: repository, tag, pullPolicy, imagePullSecrets
- `rails.*`: environment, extraEnv, existingSecret or secret.values, settings
  - Also: `rails.extraEnvVars[]` (full EnvVar), `rails.extraEnvFrom[]` (EnvFromSource), and `rails.encryptionEnv.enabled` toggle
- `cnpg.*`: enable operator subchart and a Cluster resource, set instances, storage
- `redis-ha.*`: enable dandydev/redis-ha subchart and configure replicas/auth (Sentinel/HA); supports `existingSecret` and `existingSecretPasswordKey`
- `redisOperator.*`: optionally install OT redis-operator (`redisOperator.enabled`) and/or render a `RedisSentinel` CR (`redisOperator.managed.enabled`); configure `name`, `replicas`, `auth.existingSecret/passwordKey`, `persistence.className/size`, scheduling knobs, and `operator.resources` (controller) / `workloadResources` (Redis pods)
- `redisSimple.*`: optional single‑pod Redis (non‑HA) when `redis-ha.enabled=false`
- `web.*`, `worker.*`: replicas, probes, resources, scheduling, **strategy** (rolling update configuration)
- `migrations.*`: strategy job or initContainer
- `simplefin.encryption.*`: enable + backfill options
- `cronjobs.*`: custom CronJobs
- `pipelock.*`: AI agent security proxy (forward proxy, MCP reverse proxy, DLP, injection scanning, request-body scanning, health watchdog, signed receipts, trusted domains, tool redirect profiles, logging, serviceMonitor, ingress, PDB, extraVolumes, extraVolumeMounts, extraConfig)
- `service.*`, `ingress.*`, `serviceMonitor.*`, `hpa.*`

## Helm tests

After installation, you can run chart tests to verify:

- The web Service responds over HTTP.
- Redis auth works when an in-cluster provider is active.

```sh
helm test sure -n sure
```

The Redis auth test uses `redis-cli -u "$REDIS_URL" -a "$REDIS_PASSWORD" PING` and passes when `PONG` is returned.

Alternatively, you can smoke test from a running worker pod:

```sh
kubectl exec -n sure deploy/$(kubectl get deploy -n sure -o name | grep worker | cut -d/ -f2) -- \
  sh -lc 'redis-cli -u "$REDIS_URL" -a "$REDIS_PASSWORD" PING'
```

## Testing locally (k3d/kind)

- Create a cluster (ensure storageclass is available).
- Install chart with defaults (CNPG + Redis included).
- Wait for CNPG Cluster to become Ready, then for Rails web and worker pods to be Ready.
- Port-forward or configure Ingress.

```sh
helm template sure charts/sure -n sure --debug > rendered.yaml  # dry-run inspection
helm upgrade --install sure charts/sure -n sure --create-namespace --wait
kubectl get pods -n sure
```

## Uninstall

```sh
helm uninstall sure -n sure
```

## Cleanup & reset (k3s)

For local k3s experimentation it's sometimes useful to completely reset the `sure` namespace, especially if CR finalizers or PVCs get stuck.

The script below is a **last-resort tool** for cleaning the namespace. It:

- Uninstalls the Helm release.
- Deletes RedisReplication and CNPG Cluster CRs in the namespace.
- Deletes PVCs.
- Optionally clears finalizers on remaining CRs/PVCs.
- Deletes the namespace.

> ⚠️ Finalizer patching can leave underlying volumes behind if your storage class uses its own finalizers (e.g. Longhorn snapshots). Use with care in production.

```
/* Detailed source-code truncated for AI context efficiency. */
```

---

## File: design/tokens/README.md

# Sure design tokens

This is where the design system actually lives. Tailwind reads from here, and any external tooling (Figma Tokens Studio, AI design tools, anything that shows up later) is meant to read the same JSON.

## Files

- `design/tokens/sure.tokens.json`: every token, hand-edited.
- `bin/tokens.mjs`: plain Node script. Compiles the JSON into Tailwind v4 CSS.
- `app/assets/tailwind/sure-design-system/_generated.css`: the build output. Generated, do not edit by hand.

## Workflow

```bash
# Edit a token:
$EDITOR design/tokens/sure.tokens.json

# Regenerate the CSS:
npm run tokens:build

# Commit both files together:
git add design/tokens/sure.tokens.json app/assets/tailwind/sure-design-system/_generated.css
```

`bin/setup` runs the build automatically on a fresh checkout.

## Versioning

The root `$version` field follows semver, scoped to the token contract:

- **Major** (`X.0.0`): breaking changes — token removed or renamed, value type changed, dark variant removed, semantic meaning changed.
- **Minor** (`1.X.0`): additive changes — new tokens, new `$extensions.sure.*` keys, new top-level groups.
- **Patch** (`1.0.X`): cosmetic / value tweaks that consumers don't need to know about — a hex shifts a few points without changing intent.

Bump it when you commit. External consumers (Tokens Studio, future Figma sync, etc.) read this to decide whether their cached snapshot is stale.

## Schema

The file uses the [W3C DTCG token format](https://design-tokens.github.io/community-group/format/): `$value`, `$type`, `$description`, `$extensions`. Tokens cross-reference via `{path.to.token}` placeholders.

```jsonc
{
  "color": {
    "white": { "$value": "#ffffff", "$type": "color" },
    "gray": {
      "500": { "$value": "#737373", "$type": "color" }
    },
    "success": {
      "$value": "{color.green.600}",
      "$type": "color",
      "$extensions": { "sure.dark": "{color.green.500}" }
    }
  }
}
```

### Top-level groups

| Key | Purpose |
|-----|---------|
| `font` | font-family stacks |
| `color` | base colors, semantic aliases (success, warning, destructive, shadow), full-scale ladders, alpha ladders |
| `budget` | budget-chart fills (need their own dark variants because Stimulus controllers reference them) |
| `border.radius` | corner radii |
| `shadow` | drop shadows, both light and dark variants |
| `animate` | named animations |
| `utility` | Tailwind `@utility` blocks: semantic surfaces, foregrounds, borders, button backgrounds, etc. |

### Custom `$extensions.sure.*`

| Extension | Where | What it does |
|-----------|-------|--------------|
| `sure.dark` | any token | Dark-mode override value. Same template syntax as `$value`. |
| `sure.alpha` | reserved | Currently unused; alpha is expressed inline via `{ref\|N%}`. Reserved for structured alpha if it's ever needed. |
| `sure.utility.prefix` | `utility.*` only | The Tailwind utility family (`bg`, `text`, `border`). Tells the build which `@apply` class to emit. |
| `sure.utility.raw` | `utility.*` only | A CSS property name (`background-color`, `box-shadow`, etc.) when the utility emits raw CSS instead of `@apply`. |
| `sure.compose` | `utility.*` only | Array of class names to `@apply`. For example, `bg-loader` is `["bg-surface-inset", "animate-pulse"]`. |

### Template strings

Anywhere a `$value` is a string:

- `{path.to.token}` resolves to `var(--path-to-token)` in the generated CSS.
- `{path.to.token|N%}` resolves to `--alpha(var(--path-to-token) / N%)` (Tailwind v4 alpha syntax).

The same syntax appears inside composite values like `shadow.xs.$value`: `"0px 1px 2px 0px {color.black|6%}"`.

### Alpha modifiers in views (`/N` syntax)

Tailwind v4's `class/N` modifier (`bg-warning/10`, `text-link/70`, etc.) only resolves on **theme colors** (anything declared under the top-level `color.*` group, which becomes `--color-*` in the generated CSS). It does **not** resolve on classes from this file's `utility.*` group, because those compile to static `@apply` blocks with no modifier-aware definition.

The mismatch is silent — Tailwind drops the unrecognized class and the element renders with no CSS for that property. Recently caught examples on `text-inverse/70`, `border-secondary/30`, and `bg-surface-inset/40` (all of which produced no class output).

Until the build script teaches custom utilities to be modifier-aware, the convention is:

- For alpha on a custom utility: pair the base class with `opacity-N`, e.g. `text-inverse opacity-70` instead of `text-inverse/70`.
- For alpha on a theme color: the `/N` modifier works as expected, e.g. `bg-warning/10`, `border-destructive/30`.

The pre-resolved alpha tints (`color.gray.tint-5`, `color.gray.tint-10`, `color.red.tint-5`, etc.) are theme colors, so `bg-gray-tint-5` and similar work as straight utilities and accept further `/N` modifiers.

### Adding a new token

1. Pick the right top-level group.
2. Add the `$value` (raw or `{ref}`) and `$type`.
3. If it should change in dark mode, add `$extensions.sure.dark`.
4. If it's a utility, add `$extensions.sure.utility.prefix` (or `raw`, or `compose`).
5. Run `npm run tokens:build`.
6. Look at the diff in `_generated.css` and confirm it's what you expected.
7. Commit both files.

### Edge cases the build script handles

- `color.gray.DEFAULT`: the `DEFAULT` segment is dropped in the CSS variable name (`--color-gray`, not `--color-gray-DEFAULT`). DTCG convention; matches Tailwind.
- `utility.border-divider`: the value is a plain class string (`border-tertiary`) instead of a `{ref}`. The build treats values without `{}` as raw `@apply` arguments.
- `utility.bg-overlay`: uses `sure.utility.raw: "background-color"` because it needs alpha rendering instead of `@apply`.
- `utility.bg-loader`: uses `sure.compose` to apply two utilities together (`bg-surface-inset animate-pulse`).
- `utility.button-bg-ghost-hover`: its dark value is a multi-class string (`bg-gray-800 text-inverse`), not a single ref. The build accepts both forms.

## Consumers

- Rails / Tailwind: via the generated CSS, automatically.
- Lookbook reference page: `/design-system/inspect/design_tokens/*` reads `sure.tokens.json` at request time.
- External tools (Figma Tokens Studio, AI design tools, etc.): point them at this file.

If a consumer wants a different shape, transform the JSON in their tooling rather than editing the source here.

---

## File: docs/onboarding/guide.md

# Welcome to Sure!

This guide aims to assist new users through:

1. Creating a Sure account
2. Adding your first accounts
3. Recording transactions

This guide also covers the differences between **asset** and **liability** accounts, a key concept for using and understanding balances in Sure!

> [!IMPORTANT]
> Sure is evolving quickly. If you find something inaccurate while following this guide, please:
> 
> - Ask in the [Discord](https://discord.gg/36ZGBsxYEK)
> - Open an [issue](https://github.com/we-promise/sure/issues/new/choose)
> - Or if you know the answer, open a [PR](https://github.com/we-promise/sure/compare)!


## 1. Creating your Sure Account

Once Sure is installed, open a browser and navigate to [localhost:3000](http://localhost:3000/sessions/new).
You will see the **login page** (pictured below). Since we do not have an account yet, click on **Sign Up** to begin. 





You'll be guided through a short series of screens to set your **login details**, **personal information**, and **preferences**.
When you arrive at the main dashboard, showing **No accounts yet**, you're all set up!





> [!Note]
> The next sections of this guide cover how to **manually add accounts and transactions** in Sure.
> If you'd like to use an integration with a data provider instead, see:
> 
> - [**Lunch Flow**](https://www.lunchflow.app/)
> - [**Plaid**](/docs/hosting/plaid.md)
> - [**Redbark**](/docs/hosting/redbark.md) (Australian banks)
> - [**SimpleFIN**](https://beta-bridge.simplefin.org/)
> - [**Enable Banking**](https://enablebanking.com/) (beta)
> - [**CoinStats**](https://coinstats.app/) (beta)
>
> Even if you use an integration, we still recommend reading through this guide to understand **account types** and how they work in Sure.


## 2. Account Types in Sure

Sure supports several account types, which are grouped into **Assets** (things you own) and **Debts/Liabilities** (things you owe):

| Assets      | Debts/Liabilities |
| ----------- | ----------------- |
| Cash        | Credit Card       |
| Investment  | Loan              |
| Crypto      | Other Liability   |
| Property    |                   |
| Vehicle     |                   |
| Other Asset |                   |


## 3. How Asset Accounts Work

Cash, checking and savings accounts **increase** when you add money and **decrease** when you spend money.

Example:

- Starting balance: $500
- Add an expense of $20 -> balance is now $480
- Add an income of $100 -> balance is now $580


## 4. How Debt Accounts Work (Liabilities)

Liability accounts track how much money you **owe**, so the math can feel *backwards* compared to an asset account.

**Key rule:**

- **Positive Balances** = you owe money
- **Negative balances** = the bank owes *you* (e.g. overpayment or refund)

**Transactions behave like this:**

- **Expenses** (e.g. purchases) => increase your debt (you owe more)
- **Payments or refunds** => decrease your debt (you owe less)

Credit Card example:

1. Balance: **$200 owed**
2. Spend $20 => You now owe $220 (balance goes *up* in red)
3. Pay off $50 => You now owe $170 (balance goes *down* in green)

Overpayment Example:

1. Balance: -$44 (bank owes you $44)
2. Spend $1 => Bank now owes you **$43** (balance shown as -$43, moving towards zero)

> [!TIP]
> Why does it work this way? This matches standard accounting and what your credit card provider shows online. Think of a liability balance as "**Amount Owed**", not "available cash."


## 5. Quick Reference: Assets vs. Liability Behavior

| Action           | Asset Account (e.g. Checking) | Liability Account (e.g. Credit Card) |
| ---------------- | ----------------------------- | ------------------------------------ |
| Spend $20        | Balance ↓ $20                 | Balance ↑ $20 (more debt)            |
| Receive $50      | Balance ↑ $50                 | Balance ↓ $50 (less debt)            |
| Negative Balance | Overdraft                     | Bank owes *you* money                |


## 6. Adding Accounts

For this example we'll add a **Savings Account**.

>[!TIP]
>If you're adding a **credit card**, **loan**, or any other **debt**, be sure to select a **Credit Card** or **Liability** account type instead of **Cash**. This will ensure balances update correctly and match what your bank shows.

Most bank accounts (checking, savings, money market) are **Cash Accounts**
1. Click **+ Add Account** → **Cash** → **Enter Account Balance**
2. Fill in details such as:
   - Account name
   - Current Balance
   - Account Subtype (This is where you specify checking, savings, or other)
3. Click **Create Account** when you are ready to proceed.





Once created, you'll return to the **Home** screen.
You'll now see:
- Your new cash account in the **Accounts** list (left side)
- An overview of your accounts in the center, under the net worth bar.

To get this bar moving let's add some transactions!



## 7. Adding Transactions

To add a transaction:
1. Go to the **Transactions** page (left sidebar, under **Home**, above **Budgets**)
2. Click **+ New Transaction** (top right)
3. Choose the transaction type:
   - **Expense** → Spending money
   - **Income** → Receiving money
   - **Transfer** → Move money between accounts
4. Enter the details, then click **Add transaction**

You will now see the transaction you added in your **transaction history**, as well as the **net worth chart** updating accordingly.



## 8. Managing Investment Accounts

If you're tracking investments in Sure, there are additional features to help you manage your portfolio accurately.

### Cost Basis Tracking

Cost basis tracking helps you understand the original purchase price of your investments, which is essential for calculating returns and tax reporting.

#### Cost Basis Sources

Sure tracks cost basis from three sources:

| Source | Description |
| --- | --- |
| **Manual** | User-entered values that you set directly |
| **Calculated** | Computed from your buy trades and transaction history |
| **Provider** | Imported from your financial institution (Plaid, SimpleFin, etc.) |

#### Priority Hierarchy

When multiple sources provide cost basis data, Sure uses this priority:

**Manual > Calculated > Provider**

This means:
- Manual values always take precedence
- Calculated values override provider data
- Provider data is used when no other source is available

#### Lock Protection

When you manually set a cost basis, Sure automatically locks it to prevent automatic updates from overwriting your value. This ensures your manual entries remain intact during account syncs.

#### Setting Cost Basis Manually

You can set cost basis in two ways:

**From the Holdings List:**

1. Navigate to your investment account
2. Find the holding in your portfolio
3. Click the pencil icon next to the average cost
4. Enter either:
   - **Total cost basis**: The total amount you paid for all shares
   - **Per-share cost**: The average price per share
5. The form automatically converts between total and per-share values
6. Click **Save**

The system will show a confirmation if you're overwriting an existing cost basis.




**From the Holding Drawer:**

1. Click on a holding to open its detail drawer
2. In the Overview section, click the pencil icon next to "Average Cost"
3. Enter the cost basis (total or per-share)
4. Click **Save**

After saving, you'll see:
- A lock icon indicating the value is protected
- A source label showing "(manual)"

#### Unlocking Cost Basis

If you want to allow automatic updates to recalculate your cost basis:

1. Open the holding drawer
2. Scroll to the **Settings** section
3. Find "Cost basis locked"
4. Click **Unlock**

After unlocking:
- The lock icon disappears
- Future syncs can update the cost basis
- Calculated values (from trades) will replace the manual value



#### Bidirectional Conversion

The cost basis editor provides real-time conversion between total and per-share values:

- Enter total cost → automatically calculates per-share cost
- Enter per-share cost → automatically calculates total cost

This makes it easy to enter cost basis in whichever format you have available.

### Investment Activity Labels

Activity labels help you classify and understand investment transactions. They appear as badges in your transaction list and can be used to organize and filter your investment activity.

#### Available Activity Types

Sure supports these investment activity labels:

| Label | Description |
| --- | --- |
| **Buy** | Purchase of securities |
| **Sell** | Sale of securities |
| **Contribution** | Money added to the investment account |
| **Withdrawal** | Money removed from the investment account |
| **Dividend** | Dividend payments received |
| **Interest** | Interest earned |
| **Reinvestment** | Dividends or distributions reinvested |
| **Sweep In** | Cash swept into the account |
| **Sweep Out** | Cash swept out of the account |
| **Fee** | Account or transaction fees |
| **Exchange** | Currency or security exchanges |
| **Transfer** | Transfers between accounts |
| **Other** | Miscellaneous transactions |

#### Setting Activity Labels

You can set activity labels in two ways:

**Manually for Individual Transactions:**

1. Open a transaction from an investment or crypto account
2. Scroll to the **Settings** section
3. Find "Activity type"
4. Select a label from the dropdown
5. The change saves automatically

**Automatically with Rules:**

Create rules to automatically label transactions based on patterns:

1. Go to **Settings > Rules**
2. Create a new rule
3. Set conditions (e.g., "IF transaction name contains 'DIVIDEND'")
4. Add action: "Set investment activity label"
5. Choose the label (e.g., "Dividend")
6. Save the rule



Example rules:
- IF name contains "DIVIDEND" THEN set label to "Dividend"
- IF name contains "INTEREST" THEN set label to "Interest"
- IF name contains "FEE" THEN set label to "Fee"

Rules apply automatically to new transactions and can be run on existing transactions.

#### Viewing Activity Labels

Activity labels appear as badges in:
- Transaction lists
- Transaction detail drawers
- Account activity views

They help you quickly identify the nature of each investment transaction without reading the full transaction name.

## 9. Next Steps

Now that you have one account and your first transaction:
- Explore the other account types that Sure offers, adding ones relevant to your finances.
- **Categorize** and **Tag** transactions for better searching and reporting.
- Experiment with **Budgets** to track your spending habits.
- If you have many historical transactions, use **Bulk Import** to load them in.

More detailed user guides for these features are coming soon™.

---

## File: docs/llm-guides/adding-a-securities-provider.md

# Adding a New Securities Price Provider

This guide covers every step needed to add a new securities price provider (like Tiingo, EODHD, etc.) to the application.

## Architecture Overview

```text
User searches ticker in combobox
  → SecuritiesController#index
    → Security.search_provider (queries all enabled providers concurrently)
      → Provider::YourProvider#search_securities
        → Returns results with provider key attached
          → User selects one → price_provider stored on Security record

Account sync / price fetch
  → Security#price_data_provider (looks up provider by security.price_provider)
    → Provider::YourProvider#fetch_security_prices
      → Security::Price::Importer gap-fills and upserts into DB
```

Key files:
- Provider class: `app/models/provider/your_provider.rb`
- Registry: `app/models/provider/registry.rb`
- Settings: `app/models/setting.rb`
- Provider resolution: `app/models/security/provided.rb`
- Price import: `app/models/security/price/importer.rb`
- Market data sync: `app/models/account/market_data_importer.rb`

## Step 1: Create the Provider Class

Create `app/models/provider/your_provider.rb`:

```
/* Detailed source-code truncated for AI context efficiency. */
```

### Data Structures (defined in `SecurityConcept`)

All methods must return these exact types (wrapped in `with_provider_response`):

```ruby
SecurityConcept::Security    = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic, :country_code, :currency)
SecurityConcept::SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind, :exchange_operating_mic)
SecurityConcept::Price       = Data.define(:symbol, :date, :price, :currency, :exchange_operating_mic)
```

### Error Handling

All public methods must be wrapped in `with_provider_response`:

```ruby
def some_method
  with_provider_response do
    # Your logic. Raise on errors.
    # The block return value becomes response.data
  end
end
```

Callers always receive a `Provider::Response`:
```ruby
response = provider.fetch_security_price(...)
response.success?  # true/false
response.data      # the return value from the block
response.error     # Provider::Error instance (on failure)
```

### Rate Limiting

If your provider has rate limits, include `RateLimitable` and use cache-based atomic counters:

```ruby
include RateLimitable

MIN_REQUEST_INTERVAL = 1.5 # seconds between requests

MAX_REQUESTS_PER_DAY = 20

private

  def throttle_request
    super # enforces MIN_REQUEST_INTERVAL
    enforce_daily_limit!
  end

  def enforce_daily_limit!
    cache_key = "your_provider:daily:#{Date.current}"
    count = Rails.cache.increment(cache_key, 1, expires_in: 1.day, initial: 0)
    raise RateLimitError, "Daily limit reached" if count > MAX_REQUESTS_PER_DAY
  end
```

### Exchange Mapping

Every provider has its own naming for exchanges. You must map them to ISO 10383 MIC codes. Define bidirectional maps:

```ruby
# Provider exchange name → MIC code (for parsing search results)
PROVIDER_TO_MIC = { "NASDAQ" => "XNAS", ... }.freeze

# MIC code → Provider exchange name (for building API requests)
MIC_TO_PROVIDER = PROVIDER_TO_MIC.invert.freeze
```

See `config/exchanges.yml` for the full list of MIC codes and their display names.

### Currency Handling

Some providers don't return currency in every response. Common pattern: cache currency from search results and reuse later:

```ruby
def search_securities(symbol, **opts)
  with_provider_response do
    results = api_call(...)
    results.each do |r|
      Rails.cache.write("your_provider:currency:#{r[:ticker].upcase}", r[:currency], expires_in: 24.hours)
    end
    # ...
  end
end

def fetch_security_prices(symbol:, **)
  with_provider_response do
    # ...
    currency = Rails.cache.read("your_provider:currency:#{symbol.upcase}") || fallback_currency(exchange)
    # ...
  end
end
```

## Step 2: Register in the Provider Registry

Edit `app/models/provider/registry.rb`:

**Add to `available_providers`** (around line 144):

```ruby
def available_providers
  case concept
  when :exchange_rates
    %i[twelve_data yahoo_finance]
  when :securities
    %i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi your_provider]
  # ...
  end
end
```

**Add the factory method** (private section, around line 85):

```ruby
def your_provider
  api_key = ENV["YOUR_PROVIDER_API_KEY"].presence || Setting.your_provider_api_key # pipelock:ignore
  return nil unless api_key.present?
  Provider::YourProvider.new(api_key)
end
```

If your provider needs no API key (like Yahoo Finance or MFAPI):

```ruby
def your_provider
  Provider::YourProvider.new
end
```

## Step 3: Add Settings

Edit `app/models/setting.rb`:

**Add the API key field** (around line 40):

```ruby
field :your_provider_api_key, type: :string, default: ENV["YOUR_PROVIDER_API_KEY"]
```

**Add to encrypted fields** (in `EncryptedSettingFields`, around line 55):

```ruby
ENCRYPTED_FIELDS = %i[
  twelve_data_api_key
  tiingo_api_key
  eodhd_api_key
  alpha_vantage_api_key
  your_provider_api_key    # ← add here
  openai_access_token
  external_assistant_token
]
```

## Step 4: Add to the Settings UI

### Hostings Controller

Edit `app/controllers/settings/hostings_controller.rb`:

**In `show`** — add a flag to control visibility:

```ruby
@show_your_provider_settings = enabled_securities.include?("your_provider")
```

**In `update`** — handle the API key:

```ruby
update_encrypted_setting(:your_provider_api_key)
```

### Hostings View

Edit the settings view to add your provider's checkbox and API key field. Follow the existing pattern for Tiingo/EODHD (checkbox in the provider selection list, API key input shown when enabled).

## Step 5: Add Translations

Edit `config/locales/views/settings/hostings/en.yml`:

**Add provider name** (under `provider_selection.providers`):

```yaml
providers:
  twelve_data: "Twelve Data"
  yahoo_finance: "Yahoo Finance"
  tiingo: "Tiingo"
  eodhd: "EODHD"
  alpha_vantage: "Alpha Vantage"
  mfapi: "MFAPI.in"
  your_provider: "Your Provider"    # ← add here
```

**Add hint text** (under `provider_selection`):

```yaml
your_provider_hint: "requires API key, N calls/day limit"
```

**Add settings section** (for the API key input):

```yaml
your_provider_settings:
  title: "Your Provider"
  label: "API Key"
  env_configured_message: "The YOUR_PROVIDER_API_KEY environment variable is set."
```

Also add a display name in `config/locales/en.yml` under `securities.providers`:

```yaml
securities:
  providers:
    your_provider: "Your Provider"
```

This is used in the combobox dropdown to show which provider each search result comes from.

## Step 6: Test

### Manual Testing

```ruby
# In rails console:
provider = Provider::YourProvider.new("your_api_key")

# Search
response = provider.search_securities("AAPL")
response.success? # => true
response.data     # => [SecurityConcept::Security(...), ...]

# Price
response = provider.fetch_security_price(symbol: "AAPL", exchange_operating_mic: "XNAS", date: Date.current)
response.data.price    # => 150.25
response.data.currency # => "USD"

# Historical prices
response = provider.fetch_security_prices(symbol: "AAPL", exchange_operating_mic: "XNAS", start_date: 30.days.ago.to_date, end_date: Date.current)
response.data.size # => ~30
```

### Enable and Search

```ruby
# Enable the provider
Setting.securities_providers = "your_provider"

# Search via the app's multi-provider system
results = Security.search_provider("AAPL")
results.map { |s| [s.ticker, s.price_provider] }
# => [["AAPL", "your_provider"]]

# Create a security with your provider
security = Security::Resolver.new("AAPL", exchange_operating_mic: "XNAS", price_provider: "your_provider").resolve
security.price_provider # => "your_provider"

# Import prices
security.import_provider_prices(start_date: 30.days.ago.to_date, end_date: Date.current)
security.prices.count
```

## How It All Connects

### Search Flow

When a user types in the securities combobox:

1. `SecuritiesController#index` calls `Security.search_provider(query)` (`app/models/security/provided.rb`)
2. `search_provider` queries **all enabled providers concurrently** using `Concurrent::Promises` with an 8-second timeout per provider
3. Results are deduplicated (key: `ticker|exchange|provider`) and ranked by relevance
4. Each result's `ComboboxOption#id` is `"TICKER|EXCHANGE|PROVIDER"` (e.g., `"AAPL|XNAS|your_provider"`)
5. When the user selects one, `price_provider` is stored on the Security record

### Price Fetch Flow

When prices are needed:

1. `Security#price_data_provider` looks up the provider by `security.price_provider`
2. If the assigned provider is unavailable (disabled in settings), returns `nil` — the security is skipped, not silently switched
3. If no provider assigned, falls back to the first enabled provider
4. `Security::Price::Importer` calls `fetch_security_prices` and gap-fills missing dates using LOCF (last observation carried forward)
5. Prices are upserted in batches of 200

### Provider Resolution Priority

```text
security.price_provider present?
  ├── YES → Security.provider_for(price_provider)
  │         ├── Provider enabled & configured → use it
  │         └── Provider unavailable → return nil (skip security)
  └── NO  → Security.providers.first (first enabled provider)
```

## Checklist

- [ ] Provider class at `app/models/provider/your_provider.rb`
  - [ ] Inherits from `Provider`
  - [ ] Includes `SecurityConcept`
  - [ ] Implements `search_securities`, `fetch_security_info`, `fetch_security_price`, `fetch_security_prices`
  - [ ] Returns correct `Data.define` types
  - [ ] All methods wrapped in `with_provider_response`
  - [ ] Exchange names mapped to MIC codes
  - [ ] Currency handling (cached or mapped)
  - [ ] Rate limiting if applicable
- [ ] Registry entry in `app/models/provider/registry.rb`
  - [ ] Added to `available_providers` for `:securities`
  - [ ] Private factory method with ENV + Setting fallback
- [ ] Setting field in `app/models/setting.rb` (if API key needed)
  - [ ] `field :your_provider_api_key`
  - [ ] Added to `ENCRYPTED_FIELDS`
- [ ] Settings UI in hostings controller/view
- [ ] Translations in `config/locales/`
  - [ ] Provider name in `provider_selection.providers`
  - [ ] Provider name in `securities.providers` (for combobox display)
  - [ ] Hint text and settings section
- [ ] Tested: search, single price, historical prices, info

---

## File: docs/llm-guides/gating-a-preview-feature.md

# Gating a preview feature

Sure ships preview features behind a single per-user toggle. Users opt in via Settings → Preferences. Opted-in users see your feature; everyone else doesn't. This guide is for hooking a new feature into the gate.

The intent is to ship in-progress work without blocking smaller PRs on a "feels finished" bar. You gate the entry points (routes, nav, anything that links into your feature) and iterate behind them. Once stable, you remove the gate in a small follow-up PR.

## How the gate works

The state lives on `users.preferences["preview_features_enabled"]`, a key inside the existing JSONB column. It defaults to `false`. Reading it goes through `User#preview_features_enabled?`.

`ApplicationController` includes the `PreviewGateable` concern, which exposes two methods to every controller:

- `preview_features_enabled?`. Returns a boolean. `false` for logged-out callers.
- `require_preview_features!`. A `before_action` helper. Redirects users without preview access to `/` with a flash that points them at Settings → Preferences.

The concern also registers `preview_features_enabled?` as a helper method, so views can call it directly.

Key files:

- `app/controllers/concerns/preview_gateable.rb`. The concern.
- `app/models/user.rb`. The `preview_features_enabled?` predicate.
- `app/views/settings/preferences/show.html.erb`. The toggle UI users see.
- `app/components/DS/pill.rb`. The `Preview` / `Canary` marker pill.
- `config/locales/views/preview/en.yml`. The redirect flash copy.

## Gating a controller

Add `require_preview_features!` as a `before_action`. That's it.

```ruby
class GoalsController < ApplicationController
  before_action :require_preview_features!
end
```

Routes stay defined; the gate runs per-request. Users without preview access hitting `/goals` get redirected with a flash. Preview users pass through.

If only some actions are gated, scope the `before_action`:

```ruby
class TransactionsController < ApplicationController
  before_action :require_preview_features!, only: %i[forecast scenarios]
end
```

## Gating a view

Wrap the relevant fragment in the helper:

```erb
<% if preview_features_enabled? %>
  <li>
    <%= link_to t(".nav.goals"), goals_path %>
  </li>
<% end %>
```

Same pattern works for dashboard widgets, scoreboard cards, anything that surfaces preview data alongside non-preview data. The helper resolves on every request and reflects the current user's preference.

## Gating the main nav

The desktop sidebar rail and the mobile bottom nav both render from `app/views/layouts/shared/_nav_item.html.erb`. The partial accepts an optional `preview:` local — when true, it overlays a violet dot-only pill on the icon so opted-in users can tell at a glance that the rail entry leads to a preview surface.

Use the `preview_gated_nav_item` helper to wrap the entry. It returns `nil` for users without preview access (so the entry never enters the nav, once `Array#compact` runs) and stamps `preview: true` for opted-in users (so the partial paints the dot). One call, both halves of the gate:

```erb
<% mobile_nav_items = [
  { name: t(".nav.home"), path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) },
  { name: t(".nav.transactions"), path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) },
  preview_gated_nav_item({ name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path) }),
  { name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true }
].compact %>
```

You don't need to touch `_nav_item.html.erb` or set `preview: true` by hand. Adding a new preview nav entry is one helper call wrapped around the same hash you'd write anyway.

## Marking the feature in the UI

When a preview surface renders for an opted-in user, mark it. The pill component lives in the design system:

```erb
<%# Next to a page header. The md size pairs with h1 / h2. %>
<%= render DS::Pill.new(label: "Preview", size: :md) %>

<%# Next to a sidebar nav label or section title. sm is the default. %>
<%= render DS::Pill.new(label: "Preview") %>

<%# Same shape, fuchsia tone, for canary / experimental surfaces. %>
<%= render DS::Pill.new(label: "Canary", tone: :fuchsia) %>

<%# Sidebar icon rail has no room for a label. The dot-only mode keeps the tone semantics without the text. %>
<%= render DS::Pill.new(tone: :violet, dot_only: true, title: "Preview") %>
```

Default tone is violet. Tones available: `violet`, `indigo`, `fuchsia`, `amber`, `gray`. Styles: `soft` (default), `filled`, `outline`. Sizes: `sm` (default), `md`. The Lookbook preview at `/design-system` (look for `PillComponentPreview#default`) flips every option, so you can see what your call site renders without a round trip to Rails.

## Tests

Gated controllers should test both states. The pattern:

```ruby
class GoalsControllerTest < ActionDispatch::IntegrationTest
  setup do
    sign_in @user = users(:family_admin)
  end

  test "redirects users without preview access" do
    @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))

    get goals_url

    assert_redirected_to root_path
    assert_match(/preview/i, flash[:alert])
  end

  test "renders for users with preview access" do
    @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))

    get goals_url

    assert_response :success
  end
end
```

If you write a system test, flip the preference in setup the same way before the visit.

## Removing the gate when the feature ships GA

When a feature moves from preview to general availability, removing the gate is a small mechanical PR:

1. Drop the `before_action :require_preview_features!` line from the controller.
2. Unwrap the `if preview_features_enabled?` blocks in views.
3. Drop the `DS::Pill` markers from headers and section titles, and unwrap the `preview_gated_nav_item(...)` call back into a plain nav-item hash.
4. Delete the controller / view tests that exercise the redirect.

Grep for `require_preview_features!` and `preview_features_enabled?` near your feature to confirm nothing's left behind.

## Notes

The flag is per-user, not per-family. Two users in the same family can see different versions of the product if one opts in and the other doesn't. That's intentional. Data is family-scoped, but visibility is a personal preference. If you write a feature that creates family-shared data (goals, budgets, etc.), the data persists when a user toggles preview off. The UI just disappears from their view while still showing up for opted-in family members.

The gate does nothing for background jobs on its own — `PreviewGateable` reads `Current.user`, which a Sidekiq worker doesn't have. A cron job runs regardless of who has preview enabled. That's usually correct: data should keep flowing, so it's there when someone opts in. `SweepExpiredGoalPledgesJob` is the typical shape — it only walks pledges that opted-in users created, so it's naturally inert for everyone else and needs no gate.

Gate the job when it does work that isn't free. Two cases: it sends something outward (notifications, emails — gate at the send site), or it *manufactures* data for every family rather than moving existing data around, burning compute or paid API calls per family. `GenerateInsightsJob` is the second case: seven generators over the income statement and balance sheet, plus optional LLM narration, for every family nightly.

For a family-scoped job, roll the per-user flag up to the family:

```ruby
# app/models/family.rb
scope :with_preview_features, -> { where(id: User.with_preview_features.select(:family_id)) }

def preview_features_enabled?
  users.with_preview_features.exists?
end
```

Filter the fan-out with the scope (one indexed query — `User.with_preview_features` is a jsonb containment match against the GIN index on `users.preferences`, not a load-and-iterate), and re-check with the predicate inside the per-family path, which is reachable directly from controllers and the console. Keep the same predicate name on both models so the GA-removal grep finds every call site. `GenerateInsightsJob` is the reference implementation.

One opted-in member is enough to generate for the whole family — consistent with the per-user-visibility / family-scoped-data split described above.

Note what the shared name costs: `preview_features_enabled?` means "I opted in" on `User` but "somebody in my household opted in" on `Family`. Only ever gate background work on the family form. Gating UI on it (`Current.family.preview_features_enabled?` reads naturally, which is the trap) would show the feature to someone who explicitly opted out because a family member opted in. For anything a person sees, use the `PreviewGateable` helper, which reads `Current.user`.

The redirect target is `/`. If you want gated controllers to land somewhere else (a docs page, an opt-in nudge), override `require_preview_features!` in the controller, or write a thin custom `before_action` that calls `preview_features_enabled?` directly.

---

## File: docs/llm-guides/goals.md

# Working with the Goals feature

Reference for changes to the savings-goals feature. Covers the data
model, the surfaces that consume it, the load-bearing invariants, and
the gotchas worth knowing before you touch the code.

## Architecture overview

```text
GoalsController#index
  → @active_goals = Family.goals.includes(:open_pledges, linked_accounts: :account_providers)
  → KPI strip + per-goal cards (Goals::CardComponent)
  → pending-pledges callout if any goal has an open pledge

GoalsController#show
  → @goal.open_pledges.reverse_chronological → pending-pledge banners
  → progress ring (Goals::ProgressRingComponent)
  → projection chart (data-controller="goal-projection-chart")
  → Goals::FundingAccountsBreakdownComponent (linked-account rows)
  → Notes section if @goal.notes.present?

GoalPledgesController#create (turbo-frame: modal)
  → goal.goal_pledges.new(amount:, account:, kind: kind_for_account(account))
  → save! → matches?-loop runs once the next sync arrives

Account::ProviderImportAdapter#import_transaction
  → GoalPledge::Reconciler.new(entry).run  (transfer-kind path)
Account::ReconciliationManager#reconcile
  → GoalPledge::Reconciler.new(prepared_valuation).run  (manual_save path)

SweepExpiredGoalPledgesJob (cron, every 15 minutes)
  → GoalPledge.open_and_expired_now.find_each(&:expire!)
```

## Key files

Model layer:

- `app/models/goal.rb` — balance, pace, status, projection, color map.
- `app/models/goal_pledge.rb` — pledge, match policy, lifecycle.
- `app/models/goal_pledge/reconciler.rb` — entry-to-pledge resolver, called from the import adapters.
- `app/models/account.rb` — `#manual?` instance method (mirrors the `Account.manual` scope) drives pledge kind detection.
- `app/models/family.rb` — `#savings_inflow_velocity` powers the KPI strip.

Controllers / routes:

- `app/controllers/goals_controller.rb` — index / show / new / create / edit / update / destroy (any state) / pause / resume / complete / archive / unarchive.
- `app/controllers/goal_pledges_controller.rb` — new / create / renew / destroy.
- `config/routes.rb` — `resources :goals do resources :pledges ... member { patch :renew } end`.

Views:

- `app/views/goals/index.html.erb`, `show.html.erb`, `new.html.erb`, `edit.html.erb`.
- `app/views/goals/_form_stepper.html.erb`, `_form_edit.html.erb`, `_pending_pledge_banner.html.erb`, `_empty_state.html.erb`, `_color_picker.html.erb`.
- `app/views/goal_pledges/new.html.erb`.

View components:

- `app/components/goals/card_component.{rb,html.erb}` — goal card on the index.
- `app/components/goals/funding_accounts_breakdown_component.{rb,html.erb}` — per-account widget on show.
- `app/components/goals/avatar_component.{rb,html.erb}` — colored letter/icon avatar.
- `app/components/goals/account_stack_component.{rb,html.erb}` — overlapping account avatars on the card.
- `app/components/goals/progress_ring_component.{rb,html.erb}` — show-page ring.
- `app/components/goals/status_pill_component.{rb,html.erb}` — status chip.

Stimulus controllers:

- `app/javascript/controllers/goal_stepper_controller.js` — two-step create modal.
- `app/javascript/controllers/goal_pledge_preview_controller.js` — live amount-impact preview + helper-text toggle.
- `app/javascript/controllers/goal_projection_chart_controller.js` — D3 projection chart on show.
- `app/javascript/controllers/goals_filter_controller.js` — index filter chips + search, with URL state.

Schema / migrations:

- `db/migrate/20260514120000_create_goal_pledges.rb` — table + enums + partial indexes + amount check.
- `db/migrate/20260514120001_drop_goal_contributions.rb` — old ledger.
- `db/migrate/20260514120002_add_pledge_id_index_to_transactions.rb` — partial unique on `transactions.extra->'goal'->>'pledge_id'`.

Tests / fixtures:

- `test/models/goal_test.rb`, `goal_pledge_test.rb`, `goal_pledge/reconciler_test.rb`.
- `test/controllers/goals_controller_test.rb`, `goal_pledges_controller_test.rb`.
- `test/jobs/sweep_expired_goal_pledges_job_test.rb`.
- `test/fixtures/goals.yml`, `goal_accounts.yml`, `goal_pledges.yml`.

Locales:

- `config/locales/views/goals/en.yml`, `goal_pledges/en.yml`.
- `config/locales/models/goal/en.yml`, `goal_pledge/en.yml`.

## Data model

A goal records a name, target amount, optional target date, color, optional
icon, optional notes, currency, and an AASM `state` (`active` / `paused` /
`completed` / `archived`). It links to depository accounts via the join
table `goal_accounts`.

The goal's *progress* is the live balance of every linked account. There
is no ledger of contributions. `Goal#current_balance` reads
`linked_accounts.sum(:balance)` at request time.

A `GoalPledge` is an intent: amount, account, kind, status, expires_at.
The status enum is `open` / `matched` / `cancelled` / `expired`. The kind
enum is `transfer` / `manual_save`; kind is decided at create time from
the selected account's connection state.

## Status semantics

`Goal#status` is computed at render time:

- `:reached` when `progress_percent >= 100`.
- `:no_target_date` when `target_date.nil?`.
- `:on_track` when the goal has a deadline and `monthly_target_amount <= pace`.
- `:behind` otherwise.

The AASM `state` is independent. Read `Goal#display_status` (not `#status`)
to get the right pill label: it returns the AASM state when it's not
`:active`, otherwise falls through to `#status`.

`archived` is a "keep it as a record" state, **not** a prerequisite for
deleting. `GoalsController#destroy` accepts a goal in any state. The only
affordance is the kebab on the show page — index cards deliberately carry
no actions, so the card stays a single click target. A delete cascades
only to `goal_accounts` and `goal_pledges`;
`GoalPledge#clear_matched_transaction_extra` unstamps
`extra["goal"]["pledge_id"]` from any transaction a matched pledge
claimed. No account, balance, entry or transaction is removed. Reuse
`Goal#deletion_confirm` rather than building a second `CustomConfirm` at
a new call site.

`Goal#pace` is the rolling 90-day net inflow into the linked accounts,
divided by three. The query joins `entries` with `transactions`
(valuations excluded by join shape), drops excluded entries, and drops
pending provider transactions via `Transaction.excluding_pending`. This
last filter matters: a pending Plaid deposit that later reverses would
otherwise quietly reshape pace.

`Goal#monthly_target_amount` is `(remaining_amount / months_remaining).ceil(2)`.
`months_remaining` uses day precision: `(target_date - Date.current) / 30.0`,
clamped at zero. Calendar-month math is wrong here — it produces a cliff
in the last 30 days where the required monthly rate spikes.

`Goal#catch_up_delta_money` returns `max(0, monthly_target - pace -
sum_of_open_pledges)`. The show-page catch-up alert hides when this is
zero; the pledge CTA inside the alert pre-fills with this delta, so
accepting it once funds the gap rather than stacking the full required
rate on top.

## Pledge match window

`GoalPledge#matches?` checks three things:

1. The pledge is open.
2. The entry is on the pledge's `account_id`.
3. The entry's `date` sits in `[created_at - 5d, max(created_at + 5d, expires_at)]`,
   and the entry's `|amount|` is within `$0.50` or `1%` of the pledge
   amount, whichever is larger.

The upper-bound date widens when `extend!` pushes `expires_at` forward.
Without that widening, "Extend 7 days" would push the expiry forward but
the actual match window would stay anchored at creation.

The reconciler picks pledges by `(account_id, status: "open", kind:
expected_kind, expires_at >= NOW())`. `expected_kind` is `"manual_save"`
for valuation entries and `"transfer"` for transactions.

When a pledge resolves on a transaction, the reconciler stamps
`transaction.extra["goal"]["pledge_id"] = pledge.id` and sets
`pledge.matched_transaction_id`. Two partial unique indexes enforce
single-claim semantics:

- `goal_pledges (matched_transaction_id) WHERE matched_transaction_id IS NOT NULL`
- `transactions ((extra -> 'goal' ->> 'pledge_id')) WHERE (extra -> 'goal' ->> 'pledge_id') IS NOT NULL`

`Goal#last_matched_pledge_at` joins through `matched_transaction_id` to
the entry's `date`, so the show-page header reads the actual entry date,
not `goal_pledges.updated_at`. The distinction matters: a sync resync
would otherwise touch `updated_at` on every matched pledge and reset the
"Last pledge matched N days ago" copy across every goal.

## Connected vs manual accounts

`Account#manual?` returns true when the account has no
`account_providers` association rows, no `plaid_account_id`, and no
`simplefin_account_id`. This mirrors the `Account.manual` query scope.

`Goal#any_connected_account?` returns true when *any* linked account is
not manual. It drives the modal-title copy: connected accounts get
"I just transferred…", manual-only goals get "I just saved…"

`GoalPledgesController#kind_for_account(account)` is per-account:
manual → `manual_save`, connected → `transfer`. A goal with one manual
and one connected linked account works correctly; the kind reflects the
specific account the user picked, not the goal as a whole.

## Color map

`Goal#account_color_map` returns `{ account_id => palette_hex }` for the
goal's linked accounts, sorted by id and assigned palette colors in
order. Three surfaces consume the map: `AccountStackComponent` on the
goal card, the distribution bar in the funding widget, and the avatars
in the funding widget rows. A given account renders the same color on
every surface within a goal.

Account avatars outside a goal context (the new-goal account checklist)
still call `Goals::AvatarComponent.color_for(account.name)`. The
mismatch is acceptable because the form is a one-shot picker, not a
recurring view.

## Common tasks

### Adding a new field to `Goal`

1. Migration: `add_column :goals, :your_field, :type`. Add a partial
   index if the field is queried.
2. Validation: add to `Goal` if presence/range rules apply.
3. Strong params: update `goal_params` and `goal_update_params` in
   `GoalsController`.
4. Form: surface in `app/views/goals/_form_stepper.html.erb` (create) and
   `_form_edit.html.erb` (edit).
5. Locales: add labels under `goals.form_stepper.step1.fields.*` and
   `activerecord.attributes.goal.*`.
6. Display: pick the right surface (header on show, secondary line on
   the card, etc).
7. Tests: extend `test/models/goal_test.rb` for validation; controller
   tests for the form-param flow.

### Adding a new status to `Goal#status`

The enum is implicit in the method body (symbol returns); adding a
state means touching:

1. `Goal#status` to return the new symbol from the right branch.
2. `Goal#display_status` if the new status interacts with the AASM
   states.
3. `Goals::StatusPillComponent::VARIANTS` to add the chip styling
   (classes + icon).
4. `Goals::CardComponent#footer_line` if the footer copy depends.
5. `GoalsController#kpi_payload` if the KPI strip counts it.
6. `config/locales/views/goals/en.yml` under `goals.status.*` for the
   pill label, plus chip and subtitle keys if the new status filters
   on the index.
7. `Goals::StatusPillComponent#status_key` and the goal-filter Stimulus
   controller (`data-status="..."` on chips) if the new status filters.

### Adding a new pledge kind

The kind is a Postgres enum (`goal_pledge_kind`) backing the
`GoalPledge#kind` attribute. Adding a new value:

1. Migration: `ALTER TYPE goal_pledge_kind ADD VALUE 'your_kind'`.
   This is irreversible in Postgres; consider whether you really need a
   new kind versus a different match strategy on an existing one.
2. `GoalPledge::KINDS` constant.
3. `GoalPledgesController#kind_for_account` if the new kind has a
   per-account trigger.
4. `GoalPledge::Reconciler#expected_kind` if the new kind matches a
   different entry shape.
5. Locale + modal helper text in `goal_pledges.new.helper_*`.

### Touching the reconciler

The reconciler is hot — every imported transaction across every provider
calls it. Things to watch:

- The outer `rescue StandardError` is protective: an unexpected raise
  here would break the importer for every account. Keep the rescue, but
  forward to Sentry so the underlying bug stays visible.
- The inner rescue catches `NotOpenError`, `RecordInvalid`, and
  `RecordNotUnique`. These cover the known race conditions (another
  worker claimed the pledge first; another pledge claimed the
  transaction first). Adding new exception classes here should be a
  deliberate decision.
- The `find_each` loop returns from the method on first successful
  resolve. On a rescued failure it falls through to the next candidate
  pledge.

## Gotchas

The same depository account can fund two goals. Both will read the
full balance and double-count progress toward their targets. This is a
known limitation; an allocation primitive that splits the balance
proportionally (or by explicit user weights) would be the way out.

`Goal#pace` includes paychecks, rent, debit-card spend — anything on
the linked account. For a goal linked to primary checking, the metric
matches "net change in balance," not "intentional savings." A user
living paycheck-to-paycheck shows near-zero pace even when they
consciously transfer money in. Isolating intentional savings would need
transfer-pair detection.

Status transitions on a single sub-pace month. The current behaviour is
honest but jarring; a two-month moving condition or a recovery banner
would soften the "great for five months, vacation in June, suddenly
Behind" case.

Light-mode contrast on pale palette entries is weak against
`bg-container`. The fix lives in the design system, not in the goal
feature. The distribution bar segments and the goal-card ring are the
visible surfaces.

`Goal#balance_series_values` rescues `StandardError` and logs to Sentry
when `Balance::ChartSeriesBuilder` raises. The chart degrades to
target-line-only rather than 500ing. If you're debugging "why is the
projection saved-line empty," check Sentry first.

## Demo data

`Demo::Generator#generate_goals!` seeds nine goals chosen to surface
every state on at least one card:

- Active + computed status: `:reached`, `:on_track`, `:behind`,
  `:no_target_date`, plus a past-due active goal that exercises the
  "was due" header copy.
- AASM: paused, archived, completed.
- Two open pledges (banner + index callout).
- One matched pledge bound to a real recent inflow transaction
  (exercises the "Last pledge matched N days ago" header).

Routing goals to different account pools (primary checking holds
the bulk of the balance; secondary checking holds a tenth) is what
forces certain goals to land below their target instead of overshooting.
If you change the demo's account balances, the goal targets need to
move too.

To regenerate from scratch:

```sh
bundle exec rails db:drop db:create db:schema:load
SKIP_CLEAR=1 bundle exec rake demo_data:default
```

`SKIP_CLEAR=0` clears existing data first; on a freshly-loaded schema
the clear step has known issues with the `trades` constraint so the
`SKIP_CLEAR=1` path is the reliable one.

## Background processes

`SweepExpiredGoalPledgesJob` runs every 15 minutes via sidekiq-cron
(`config/schedule.yml`). It scans `GoalPledge.open_and_expired_now` and
flips matching rows to `expired`.

`GoalPledge::Reconciler` runs synchronously inside the existing import
pipeline; it is not a separate job. Any provider sync (Plaid,
SimpleFIN, Lunchflow, Enable Banking, Brex, IBKR, Kraken, SnapTrade) and
any manual balance reconciliation feeds through `Account::ProviderImportAdapter`
or `Account::ReconciliationManager` and trips the reconciler hook.

---

## File: docs/hosting/ai.md

# LLM Configuration Guide

This document explains how Sure uses Large Language Models (LLMs) for AI features and how to configure them for your deployment.

## Overview

Sure includes an AI assistant that can help users understand their financial data by answering questions about accounts, transactions, income, expenses, net worth, and more. The assistant uses LLMs to process natural language queries and provide insights based on the user's financial data.

> [!CAUTION]
> Only `gpt-4.1` was ever supported prior to `v0.6.5-alpha*` builds!

> 👉 Help us by taking a structured approach to your issue reporting. 🙏

## Architecture: Two AI Pipelines

Sure has **two separate AI systems** that operate independently. Understanding this is important because they have different configuration requirements.

### 1. Chat Assistant (conversational)

The interactive chat where users ask questions about their finances. Routes through one of two backends:

- **Builtin** (default): Uses the OpenAI-compatible provider configured via `OPENAI_ACCESS_TOKEN` / `OPENAI_URI_BASE` / `OPENAI_MODEL`. Calls Sure's function tools directly (get_accounts, get_transactions, etc.).
- **External**: Delegates the entire conversation to a remote AI agent. The agent calls back to Sure via MCP to access financial data. Set `ASSISTANT_TYPE=external` as a global override, or configure each family's assistant type in Settings.

### 2. Auto-Categorization and Merchant Detection (background)

Background jobs that classify transactions and detect merchants. These **always** use the OpenAI-compatible provider (`OPENAI_ACCESS_TOKEN`), regardless of what the chat assistant uses. They rely on structured function calling with JSON schemas, not conversational chat.

### What this means in practice

| Setting | Chat assistant | Auto-categorization |
|---------|---------------|---------------------|
| `ASSISTANT_TYPE=builtin` (default) | Uses OpenAI provider | Uses OpenAI provider |
| `ASSISTANT_TYPE=external` | Uses external agent | Still uses OpenAI provider |

If you use an external agent for chat, you still need `OPENAI_ACCESS_TOKEN` set for auto-categorization and merchant detection to work. The two systems are fully independent.

## Quickstart: OpenAI Token

The easiest way to get started with AI features in Sure is to use OpenAI:

1. Get an API key from [OpenAI](https://platform.openai.com/api-keys)
2. Set the environment variable:
   ```bash
   OPENAI_ACCESS_TOKEN=sk-proj-...your-key-here...
   ```
3. (Re-)Start Sure (both `web` and `worker` services!) and the AI assistant will be available to use after you agree/allow via UI option

That's it! Sure will use OpenAI's with a default model (currently `gpt-4.1`) for all AI operations.

## Local vs. Cloud Inference

### Cloud Inference (Recommended for Most Users)

**What it means:** The LLM runs on remote servers (like OpenAI's infrastructure), and your app sends requests over the internet.

| Pros                             | Cons |
|------                            |------|
| Zero setup - works immediately   | Requires internet connection |
| Always uses the latest models    | Data leaves your infrastructure (though transmitted securely) |
| No hardware requirements         | Per-request costs |
| Scales automatically             | Dependent on provider availability |
| Regular updates and improvements | |

**When to use:**
- You're new to LLMs
- You want the best performance without setup
- You don't have powerful hardware (GPU with large VRAM)
- You're okay with cloud-based processing
- You're running a managed instance

### Local Inference (Self-Hosted)

**What it means:** The LLM runs on your own hardware using tools like Ollama, LM Studio, or LocalAI.

| Pros                                                | Cons |
|------                                               |------|
| Complete data privacy - nothing leaves your network | Requires significant hardware (see below) |
| No per-request costs after initial setup            | Setup and maintenance overhead |
| Works offline                                       | Models may be less capable than latest cloud offerings |
| Full control over models and updates                | You manage updates and improvements |
| Can be more cost-effective at scale                 | Performance depends on your hardware |

**Hardware Requirements:**

The amount of VRAM (GPU memory) you need depends on the model size:

- **Minimum (8GB VRAM):** Can run 7B parameter models like `llama3.2:7b` or `gemma2:7b`
  - Works for basic chat functionality
  - May struggle with complex financial analysis
  
- **Recommended (16GB+ VRAM):** Can run 13B-14B parameter models like `llama3.1:13b` or `qwen2.5:14b`
  - Good balance of performance and hardware requirements
  - Handles most financial queries well
  
- **Ideal (24GB+ VRAM):** Can run 30B+ parameter models or run smaller models with higher precision
  - Best quality responses
  - Complex reasoning about financial data
  
**CPU-only inference:** Possible but extremely slow (10-100x slower). Not recommended for production use.

**When to use:**
- Privacy is critical (regulated industries, sensitive financial data)
- You have the required hardware
- You're comfortable with technical setup
- You want to minimize ongoing costs
- You need offline functionality

## Cloud Providers

Sure supports any OpenAI-compatible API endpoint. Here are tested providers:

### OpenAI (Primary Support)

```bash
OPENAI_ACCESS_TOKEN=sk-proj-...
# No other configuration needed

# Optional: Request timeout in seconds (default: 60)
# OPENAI_REQUEST_TIMEOUT=60
```

**Recommended models:**
- `gpt-4.1` - Default, best balance of speed and quality
- `gpt-5` - Latest model, highest quality (more expensive)
- `gpt-4o-mini` - Cheaper, good quality

**Pricing:** See [OpenAI Pricing](https://openai.com/api/pricing/)

### Google Gemini (via OpenRouter)

[OpenRouter](https://openrouter.ai/) provides access to many models including Gemini:

```bash
OPENAI_ACCESS_TOKEN=your-openrouter-api-key
OPENAI_URI_BASE=https://openrouter.ai/api/v1
OPENAI_MODEL=google/gemini-2.0-flash-exp
```

**Why OpenRouter?**
- Single API for multiple providers
- Competitive pricing
- Automatic fallbacks
- Usage tracking

**Recommended Gemini models via OpenRouter:**
- `google/gemini-2.5-flash` - Fast and capable
- `google/gemini-2.5-pro` - High quality, good for complex queries

### Anthropic Claude (via OpenRouter)

```bash
OPENAI_ACCESS_TOKEN=your-openrouter-api-key
OPENAI_URI_BASE=https://openrouter.ai/api/v1
OPENAI_MODEL=anthropic/claude-3.5-sonnet
```

**Recommended Claude models:**
- `anthropic/claude-sonnet-4.5` - Excellent reasoning, good with financial data
- `anthropic/claude-haiku-4.5` - Fast and cost-effective

### Other Providers

Any service offering an OpenAI-compatible API should work:
- [Groq](https://groq.com/) - Fast inference, free tier available
- [Together AI](https://together.ai/) - Various open models
- [Anyscale](https://www.anyscale.com/) - Llama models
- [Replicate](https://replicate.com/) - Various models

## Local LLM Setup (Ollama)

[Ollama](https://ollama.ai/) is the recommended tool for running LLMs locally.

### Installation

1. Install Ollama:
   ```bash
   # macOS
   brew install ollama
   
   # Linux
   curl -fsSL https://ollama.com/install.sh | sh
   
   # Windows
   # Download from https://ollama.com/download
   ```

2. Start Ollama:
   ```bash
   ollama serve
   ```

3. Pull a model:
   ```bash
   # Smaller, faster (requires 8GB VRAM)
   ollama pull gemma2:7b
   
   # Balanced (requires 16GB VRAM)
   ollama pull llama3.1:13b
   
   # Larger, more capable (requires 24GB+ VRAM)
   ollama pull qwen2.5:32b
   ```

### Configuration

Configure Sure to use Ollama:

```bash
# Dummy token (Ollama doesn't need authentication)
OPENAI_ACCESS_TOKEN=ollama-local

# Ollama API endpoint
OPENAI_URI_BASE=http://localhost:11434/v1

# Model you pulled
OPENAI_MODEL=llama3.1:13b

# Raise this for large-context local models so auto-categorize and merchant detection
# have enough prompt budget for categories + schemas before transaction rows are added.
LLM_CONTEXT_WINDOW=8192

# Slow local models often need a longer HTTP timeout once the prompt budget issue is fixed.
OPENAI_REQUEST_TIMEOUT=180

# Chained tool calls per turn. Each iteration is another call to the model, so
# lowering this is the cheapest way to keep a turn inside the timeout below.
ASSISTANT_MAX_TOOL_CALL_ITERATIONS=2

# Whole-turn budget before the chat gives up and shows a "no response" error.
# Responses from custom providers are not streamed and tool-call rounds display
# nothing, so this covers every call the turn makes, not just the first token.
# Size it as a sum:
#   (1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) * OPENAI_REQUEST_TIMEOUT
#     + tool execution + queue wait
# Here (1 + 2) * 180 = 540s of model time, plus headroom, so 720.
AI_RESPONSE_TIMEOUT=720

# Optional: enable debug logging in the AI chat
AI_DEBUG_MODE=true 
```

**Important:** When using Ollama or any custom provider:
- You **must** set `OPENAI_MODEL` - the system cannot default to `gpt-4.1` as that model won't exist in Ollama
- The `OPENAI_ACCESS_TOKEN` can be any non-empty value (Ollama ignores it)
- If you don't set a model, chats will fail with a validation error
- Auto-categorization uses a conservative default `LLM_CONTEXT_WINDOW=2048`, so large category lists or schemas can exhaust the prompt budget before any transactions are sent
- If requests start timing out after raising `LLM_CONTEXT_WINDOW`, increase `OPENAI_REQUEST_TIMEOUT` too; these are separate limits
- Responses from custom providers are **not streamed** — the chat shows "Thinking…" until the entire reply is generated, and a turn that chains tool calls stays there through every round, since tool-call responses have no text to display. If the chat errors while your model is clearly still working, raise `AI_RESPONSE_TIMEOUT` or lower `ASSISTANT_MAX_TOOL_CALL_ITERATIONS`; `OPENAI_REQUEST_TIMEOUT` alone will not help. `AI_RESPONSE_TIMEOUT` has to cover the whole turn, so size it as `(1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) × OPENAI_REQUEST_TIMEOUT` plus tool execution and queue wait — a sum, not simply a larger number than the per-call limit

### Docker Compose Example

```yaml
services:
  sure:
    environment:
      - OPENAI_ACCESS_TOKEN=ollama-local
      - OPENAI_URI_BASE=http://ollama:11434/v1
      - OPENAI_MODEL=llama3.1:13b
      - LLM_CONTEXT_WINDOW=8192
      - OPENAI_REQUEST_TIMEOUT=180
      - AI_DEBUG_MODE=true # Optional: enable debug logging in the AI chat
    depends_on:
      - ollama
  
  ollama:
    image: ollama/ollama:latest
    ports:
      - "11434:11434"
    volumes:
      - ollama_data:/root/.ollama
    # Uncomment if you have an NVIDIA GPU
    # deploy:
    #   resources:
    #     reservations:
    #       devices:
    #         - driver: nvidia
    #           count: 1
    #           capabilities: [gpu]

volumes:
  ollama_data:
```

## Model Recommendations

> [!CAUTION]
> **REMINDER:** Only `gpt-4.1` was ever supported prior to `v0.6.5-alpha*` builds!

> 👉 Help us by taking a structured approach to your testing of the models mentioned below. 🙏

### For Chat Assistant

The AI assistant needs to understand financial context and perform **function/tool** calling:

**Cloud:**
- **Best:** `gpt-4.1` or `gpt-5` - Most reliable, best function calling
- **Good:** `anthropic/claude-4.5-sonnet` - Excellent reasoning
- **Budget:** `google/gemini-2.5-flash` - Fast and affordable

**Local:**
- **Best:** `qwen3-30b` - Strong function calling and reasoning (24GB+ VRAM, 14GB at 3bit quantised )
- **Good:** `openai/gpt-oss-20b` - Solid performance (12GB VRAM)
- **Budget:** `qwen3-8b`, `llama3.1-8b` - Minimal hardware (8GB VRAM), still supports tool calling

### For Auto-Categorization

Transaction categorization doesn't require function calling:

**Cloud:**
- **Best:** Same as chat - `gpt-4.1` or `gpt-5`
- **Budget:** `gpt-4o-mini` - Much cheaper, still very accurate

**Local:**
- Any model that works for chat will work for categorization
- This is less demanding than chat, so smaller models may suffice
- Some models don't support structured outputs, please validate when using.

### For Merchant Detection

Similar requirements to categorization:

**Cloud:**
- Same recommendations as auto-categorization

**Local:**
- Same recommendations as auto-categorization

## Configuration via Settings UI

For self-hosted deployments, you can configure AI settings through the web interface:

1. Go to **Settings** → **Self-Hosting**
2. Scroll to the **AI Provider** section
3. Configure the provider:
   - **Access Token** - Your API key
   - **API Base URL** - Custom endpoint (leave blank for OpenAI)
   - **Model** - Model name (required for custom endpoints)
   - **JSON Mode** - Structured-output format; `Auto` suits most models
4. Optionally tune **Token Budget** — Context Window, Max Response Tokens and Max Items Per Batch. The defaults are conservative so small-context local models work out of the box; raise them for cloud or large-context models.
5. Optionally set **Chat Response Timeout** — how long the chat waits for a whole turn before showing a "no response" error (default 90s). Raise it for slow local models; see [Chat Errors While the Model Is Still Generating](#chat-errors-while-the-model-is-still-generating).

**Note:** Environment variables take precedence over UI settings. When an env var is set, the corresponding UI field is disabled.

## External AI Assistant

Instead of using the built-in LLM (which calls OpenAI or a local model directly), you can delegate chat to an **external AI agent**. The agent receives the conversation, can call back to Sure's financial data via MCP, and streams a response.

This is useful when:
- You have a custom AI agent with domain knowledge, memory, or personality
- You want to use a non-OpenAI-compatible model (the agent translates)
- You want to keep LLM credentials and logic outside Sure entirely

> [!IMPORTANT]
> **Set `ASSISTANT_TYPE=external` to route all users to the external agent.** Without it, routing falls back to each family's `assistant_type` DB column (configurable per-family in the Settings UI), then defaults to `"builtin"`. If you want a global override that applies to every family regardless of their UI setting, set the env var. If you only want specific families to use the external agent, skip the env var and configure it per-family in Settings.

> [!NOTE]
> The external assistant handles **chat only**. Auto-categorization and merchant detection still use the OpenAI-compatible provider (`OPENAI_ACCESS_TOKEN`). See [Architecture: Two AI Pipelines](#architecture-two-ai-pipelines) for details.

### How It Works

1. User sends a message in the Sure chat UI
2. Sure sends the conversation to your agent's API endpoint (OpenAI chat completions format)
3. Your agent processes it using whatever LLM, tools, or context it needs
4. Your agent can call Sure's `/mcp` endpoint for financial data and actions (accounts, transactions, balance sheet, budgets, file search, statement import, goals)
5. Your agent streams the response back to Sure via Server-Sent Events (SSE)

The agent's API must be **OpenAI chat completions compatible**: accept `POST` with a `messages` array, return SSE with `delta.content` chunks.

### Configuration

Configure via the UI or environment variables:

**Settings UI:**
1. Go to **Settings** -> **Self-Hosting**
2. Set **Assistant type** to "External (remote agent)"
3. Enter the **Endpoint URL** and **API Token** from your agent provider
4. Optionally set an **Agent ID** if the provider hosts multiple agents

**Environment variables:**
```bash
ASSISTANT_TYPE=external                          # Global override (or set per-family in UI)
EXTERNAL_ASSISTANT_URL=https://your-agent/v1/chat/completions
EXTERNAL_ASSISTANT_TOKEN=your-api-token
EXTERNAL_ASSISTANT_AGENT_ID=main                 # Optional, defaults to "main"
EXTERNAL_ASSISTANT_SESSION_KEY=agent:main:main   # Optional, for session persistence
EXTERNAL_ASSISTANT_ALLOWED_EMAILS=user@example.com  # Optional, comma-separated allowlist
```

When environment variables are set, the corresponding UI fields are disabled (env takes precedence).

### MCP Callback Endpoint

Sure exposes a [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) endpoint at `/mcp` so your external agent can call back and query financial data. This is how the agent accesses accounts, transactions, balance sheets, and other user data.

**Protocol:** JSON-RPC 2.0 over HTTP POST

**Authentication:** Bearer token via `Authorization` header

Sure supports both:

- OAuth bearer tokens issued through its discovery and registration endpoints (`/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server`, `POST /register`)
- Static bearer tokens configured with `MCP_API_TOKEN` and `MCP_USER_EMAIL`

**Static-token environment variables:**
```bash
MCP_API_TOKEN=your-secret-token    # Bearer token the agent sends to authenticate
MCP_USER_EMAIL=user@example.com    # Email of the Sure user the agent acts as
```

The agent must send requests to `https://your-sure-instance/mcp` with:
```http
Authorization: Bearer <access-token>
Content-Type: application/json
```

For OAuth clients, `<access-token>` is the issued Doorkeeper bearer token. For static-token clients, it is the configured `MCP_API_TOKEN`.

**Supported methods:**

| Method | Description |
|--------|-------------|
| `initialize` | Handshake, returns server info and capabilities |
| `tools/list` | Lists available tools with names, descriptions, and input schemas |
| `tools/call` | Calls a specific tool by name with arguments |

**Available tools** (exposed via `tools/list`): the `/mcp` endpoint serves the
same registry as the builtin assistant. See the canonical tool tables in
[mcp.md](mcp.md#available-tools), which also cover the preview tools.

**Example: list tools**
```bash
curl -X POST https://your-sure-instance/mcp \
  -H "Authorization: Bearer $MCP_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

**Example: call a tool**
```bash
curl -X POST https://your-sure-instance/mcp \
  -H "Authorization: Bearer $MCP_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_accounts","arguments":{}}}'
```

### OpenClaw Gateway Example

[OpenClaw](https://github.com/luckyPipewrench/openclaw) is an AI agent gateway that exposes agents as OpenAI-compatible endpoints. If your agent runs behind OpenClaw, configure it like this:

```bash
ASSISTANT_TYPE=external
EXTERNAL_ASSISTANT_URL=http://your-openclaw-host:18789/v1/chat/completions
EXTERNAL_ASSISTANT_TOKEN=your-gateway-token
EXTERNAL_ASSISTANT_AGENT_ID=your-agent-name
```

**OpenClaw setup requirements:**
- The gateway must have `chatCompletions.enabled: true` in its config
- The agent's MCP config must point to Sure's `/mcp` endpoint with the correct `MCP_API_TOKEN`
- The URL format is always `/v1/chat/completions` (OpenAI-compatible)

**Kubernetes in-cluster example** (agent in a different namespace):
```bash
# URL uses Kubernetes DNS: <service>.<namespace>.svc.cluster.local:
EXTERNAL_ASSISTANT_URL=http://my-agent.my-namespace.svc.cluster.local:18789/v1/chat/completions
```

#### Giving the agent a long-term memory of its own

An external agent can do more than answer questions about the current balance:
with its own repository behind it, it can maintain a document-backed history of
a family's wealth, where every figure traces back to the statement it came from.

That is a two-install shape — Sure as the system of record, the agent harness as
the model and the compiler — rather than a feature inside Sure. Sure exposes a
set of preview MCP tools for it (the Statement Vault, coverage gaps, and
valuations that require a source citation).

See [Wealth history with an external agent harness](../llm-guides/wealth-agent-harness.md)
for which side owns what, and [the blueprint](../llm-guides/wealth-blueprint.md)
it implements.

### Security with Pipelock

When [Pipelock](https://github.com/luckyPipewrench/pipelock) is enabled (`pipelock.enabled=true` in Helm, or the `pipelock` service in Docker Compose), all traffic between Sure and the external agent is scanned:

- **Outbound** (Sure -> agent): routed through Pipelock's forward proxy via `HTTPS_PROXY`
- **Inbound** (agent -> Sure /mcp): routed through Pipelock's MCP reverse proxy (port 8889)

Pipelock scans for prompt injection, DLP violations, and tool poisoning. The external agent does not need Pipelock installed. Sure's Pipelock handles both directions.

If you need audit evidence, configure Pipelock's flight recorder as described in [Pipelock signed action receipts](pipelock.md#signed-action-receipts). `pipelock.enabled=true` gives scanning; receipts require mounted evidence storage plus a receipt-signing key.

**`NO_PROXY` behavior (Helm/Kubernetes only):** The Helm chart's env template sets `NO_PROXY` to include `.svc.cluster.local` and other internal domains. This means in-cluster agent URLs (like `http://agent.namespace.svc.cluster.local:18789`) bypass the forward proxy and go directly. If your agent is in-cluster, its traffic won't be forward-proxy scanned (but MCP callbacks from the agent are still scanned by the reverse proxy). Docker Compose deployments use a different `NO_PROXY` set; check your compose file for the exact values.

**`mcpToolPolicy` note:** The Helm chart's `pipelock.mcpToolPolicy.enabled` defaults to `false`. Pipelock rejects an enabled tool policy with no rules, so the chart ships it off by default. To turn it on, define at least one rule and set `enabled: true`:

```yaml
# Helm values
pipelock:
  mcpToolPolicy:
    enabled: true
    action: warn
    rules:
      - name: example
        toolPattern: "^shell$"
        action: block
```

See the [Pipelock documentation](https://github.com/luckyPipewrench/pipelock) for tool policy configuration details.

### Network Policies (Kubernetes)

If you use Kubernetes NetworkPolicies (and you should), both Sure and the agent's namespace need rules to allow traffic in both directions.

> [!WARNING]
> **Port number gotcha:** Kubernetes network policies evaluate **after** kube-proxy DNAT. This means egress rules must use the pod's `targetPort`, not the service port. If your agent's Service maps port 18789 to targetPort 18790, the network policy must allow port **18790**.

**Sure namespace egress** (Sure calling the agent):
```yaml
# Allow Sure -> agent namespace
- to:
    - namespaceSelector:
        matchLabels:
          kubernetes.io/metadata.name: agent-namespace
  ports:
    - protocol: TCP
      port: 18790  # targetPort, not service port!
```

**Sure namespace ingress** (agent calling Sure's pipelock MCP reverse proxy):
```yaml
# Allow agent -> Sure pipelock MCP reverse proxy
- from:
    - namespaceSelector:
        matchLabels:
          kubernetes.io/metadata.name: agent-namespace
  ports:
    - protocol: TCP
      port: 8889
```

**Agent namespace** needs the reverse: egress to Sure on port 8889, ingress from Sure on its listening port.

### Access Control

Use `EXTERNAL_ASSISTANT_ALLOWED_EMAILS` to restrict which users can use the external assistant. When set, only users whose email matches the comma-separated list will see the AI chat. When blank, all users can access it.

### Docker Compose Example

```yaml
x-rails-env: &rails_env
  ASSISTANT_TYPE: external
  EXTERNAL_ASSISTANT_URL: https://your-agent/v1/chat/completions
  EXTERNAL_ASSISTANT_TOKEN: your-api-token
  MCP_API_TOKEN: your-mcp-token          # For agent callback
  MCP_USER_EMAIL: user@example.com        # User the agent acts as
```

Or configure the assistant via the Settings UI after startup (MCP env vars are still required for callback).

## Assistant Architecture

Sure's AI assistant system uses a modular architecture that allows different assistant implementations to be plugged in based on configuration. This section explains the architecture for contributors who want to understand or extend the system.

### Overview

The assistant system evolved from a monolithic class to a module-based architecture with a registry pattern. This allows Sure to support multiple assistant types (builtin, external) and makes it easy to add new implementations.

**Key benefits:**
- **Extensible:** Add new assistant types without modifying existing code
- **Configurable:** Choose assistant type per family or globally
- **Isolated:** Each implementation has its own logic and dependencies
- **Testable:** Implementations are independent and can be tested separately

### Component Hierarchy

#### `Assistant` Module

The main entry point for all assistant operations. Located in `app/models/assistant.rb`.

**Key methods:**

| Method | Description |
|--------|-------------|
| `.for_chat(chat)` | Returns the appropriate assistant instance for a chat |
| `.config_for(chat)` | Returns configuration for builtin assistants |
| `.available_types` | Lists all registered assistant types |
| `.function_classes` | Returns all available function/tool classes |

**Example usage:**

```ruby
# Get an assistant for a chat
assistant = Assistant.for_chat(chat)

# Respond to a message
assistant.respond_to(message)
```

#### `Assistant::Base`

Abstract base class that all assistant implementations inherit from. Located in `app/models/assistant/base.rb`.

**Contract:**
- Must implement `respond_to(message)` instance method
- Includes `Assistant::Broadcastable` for real-time updates
- Receives the `chat` object in the initializer

**Example implementation:**

```ruby
class Assistant::MyCustom < Assistant::Base
  def respond_to(message)
    # Your custom logic here
    assistant_message = AssistantMessage.new(chat: chat, content: "Response")
    assistant_message.save!
  end
end
```

#### `Assistant::Builtin`

The default implementation that uses the configured OpenAI-compatible LLM provider. Located in `app/models/assistant/builtin.rb`.

**Features:**
- Uses `Assistant::Provided` for LLM provider selection
- Uses `Assistant::Configurable` for system prompts and function configuration
- Supports function calling via `Assistant::FunctionToolCaller`
- Streams responses in real-time

**Key methods:**

| Method | Description |
|--------|-------------|
| `.for_chat(chat)` | Creates a new builtin assistant with config |
| `#respond_to(message)` | Processes a message using the LLM |

#### `Assistant::External`

Implementation for delegating chat to a remote AI agent. Located in `app/models/assistant/external.rb`.

**Features:**
- Sends conversation to external agent via OpenAI-compatible API
- Agent calls back to Sure's `/mcp` endpoint for financial data
- Supports access control via email allowlist
- Streams responses from the agent

**Configuration:**

```ruby
config = Assistant::External.config
# => #<struct url="...", token="...", agent_id="...", session_key="...">
```

### Registry Pattern

The `Assistant` module uses a registry to map type names to implementation classes:

```ruby
REGISTRY = {
  "builtin" => Assistant::Builtin,
  "external" => Assistant::External
}.freeze
```

**Type selection logic:**

1. Check `ENV["ASSISTANT_TYPE"]` (global override)
2. Check `chat.user.family.assistant_type` (per-family setting)
3. Default to `"builtin"`

**Example:**

```ruby
# Global override
ENV["ASSISTANT_TYPE"] = "external"
Assistant.for_chat(chat) # => Assistant::External instance

# Per-family setting
family.update(assistant_type: "external")
Assistant.for_chat(chat) # => Assistant::External instance

# Default
Assistant.for_chat(chat) # => Assistant::Builtin instance
```

### Function Registry

`Assistant.function_classes(user = nil)` centralizes all available financial
tools. The full list lives in `app/models/assistant.rb` (not repeated here;
it drifts). Passing a user matters: preview tools
(`PREVIEW_FUNCTION_CLASSES`) are appended only when that user has preview
features enabled.

These functions are:
- Used by builtin assistants for LLM function calling
- Exposed via the MCP endpoint for external agents
- Defined in `app/models/assistant/function/`

### Responder loop

`Assistant::Responder` drives the tool loop. Facts that matter when adding a
function:

- The iteration cap counts **rounds** (model round-trips), not individual
  calls; parallel calls in one round count once. Default 8, override with
  `ASSISTANT_MAX_TOOL_CALL_ITERATIONS`.
- On the final permitted round the follow-up request offers **no tools**, so
  the model must answer in text with whatever it gathered (a grace turn
  instead of a dead chat). `ToolCallLimitError` remains as a backstop.
- Tool failures do not raise out of the loop. `FunctionToolCaller` returns
  `{error:, hint:}` results, and the system prompt tells the model to follow
  the hint and retry exactly once. Functions should return
  `{ error: "...", message: "..." }`-shaped soft failures for expected
  problems (unknown ids, invalid dates) rather than raising.

### System prompt structure

The prompt is `Assistant::Configurable::STATIC_INSTRUCTIONS` (a frozen,
byte-stable constant, which providers can cache as a repeated prefix)
followed by a volatile `## Session context` block: date, formats, currency,
an account roster and category names. The roster collapses to counts beyond
25 accounts, categories beyond 60 names, and both collapse whenever the
configured context window is below 4096 tokens.

### Adding a New Assistant Type

To add a custom assistant implementation:

#### 1. Create the implementation class

```ruby
# app/models/assistant/my_custom.rb
class Assistant::MyCustom < Assistant::Base
  class << self
    def for_chat(chat)
      new(chat)
    end
  end

  def respond_to(message)
    # Your implementation here
    # Must create and save an AssistantMessage
    assistant_message = AssistantMessage.new(
      chat: chat,
      content: "My custom response"
    )
    assistant_message.save!
  end
end
```

#### 2. Register the implementation

```ruby
# app/models/assistant.rb
REGISTRY = {
  "builtin" => Assistant::Builtin,
  "external" => Assistant::External,
  "my_custom" => Assistant::MyCustom
}.freeze
```

#### 3. Add validation

```ruby
# app/models/family.rb
ASSISTANT_TYPES = %w[builtin external my_custom].freeze
```

#### 4. Use the new type

```bash
# Global override
ASSISTANT_TYPE=my_custom

# Or set per-family in the database
family.update(assistant_type: "my_custom")
```

### Integration Points

#### Pipelock Integration

For external assistants, Pipelock can scan traffic:
- **Outbound:** Sure -> agent (via `HTTPS_PROXY`)
- **Inbound:** Agent -> Sure /mcp (via MCP reverse proxy on port 8889)

See the [External AI Assistant](#external-ai-assistant) and [Pipelock](pipelock.md) documentation for configuration.

#### OpenClaw/WebSocket Support

The `Assistant::External` implementation currently uses HTTP streaming. Future implementations could use WebSocket connections via OpenClaw or other gateways.

**Example future implementation:**

```ruby
class Assistant::WebSocket < Assistant::Base
  def respond_to(message)
    # Connect via WebSocket
    # Stream bidirectional communication
    # Handle tool calls via MCP
  end
end
```

Register it in the `REGISTRY` and add to `Family::ASSISTANT_TYPES` to activate.

## AI Cache Management

Sure caches AI-generated results (like auto-categorization and merchant detection) to avoid redundant API calls and costs. However, there are situations where you may want to clear this cache.

### What is the AI Cache?

When AI rules process transactions, Sure stores:
- **Enrichment records**: Which attributes were set by AI (category, merchant, etc.)
- **Attribute locks**: Prevents rules from re-processing already-handled transactions

This caching means:
- Transactions won't be sent to the LLM repeatedly
- Your API costs are minimized
- Processing is faster on subsequent rule runs

### When to Reset the AI Cache

You might want to reset the cache when:

1. **Switching LLM models**: Different models may produce better categorizations
2. **Improving prompts**: After system updates with better prompts
3. **Fixing miscategorizations**: When AI made systematic errors
4. **Testing**: During development or evaluation of AI features

> [!CAUTION]
> Resetting the AI cache will cause all transactions to be re-processed by AI rules on the next run. This **will incur API costs** if using a cloud provider.

### How to Reset the AI Cache

**Via UI (Recommended):**
1. Go to **Settings** → **Rules**
2. Click the menu button (three dots)
3. Select **Reset AI cache**
4. Confirm the action

The cache is cleared asynchronously in the background. You'll see a confirmation message when the process starts.

**Automatic Reset:**
The AI cache is automatically cleared for all users when the OpenAI model setting is changed. This ensures that the new model processes transactions fresh.

### What Happens When Cache is Reset

1. **AI-locked attributes are unlocked**: Transactions can be re-enriched
2. **AI enrichment records are deleted**: The history of AI changes is cleared
3. **User edits are preserved**: If you manually changed a category after AI set it, your change is kept

### Cost Implications

Before resetting the cache, consider:

| Scenario | Approximate Cost |
|----------|------------------|
| 100 transactions | $0.05-0.20 |
| 1,000 transactions | $0.50-2.00 |
| 10,000 transactions | $5.00-20.00 |

*Costs vary by model. Use `gpt-4o-mini` for lower costs.*

**Tips to minimize costs:**
- Use narrow rule filters before running AI actions
- Reset cache only when necessary
- Consider using local LLMs for bulk re-processing

## Observability with Langfuse

Sure includes built-in support for [Langfuse](https://langfuse.com/), an open-source LLM observability platform.

### What is Langfuse?

Langfuse helps you:
- Track all LLM requests and responses
- Monitor costs per request
- Measure response latency
- Debug failed requests
- Analyze usage patterns
- Optimize prompts based on real data

### Setup

1. Create a free account at [Langfuse Cloud](https://cloud.langfuse.com/) or [self-host Langfuse](https://langfuse.com/docs/deployment/self-host)

2. Get your API keys from the Langfuse dashboard

3. Configure Sure:
   ```bash
   LANGFUSE_PUBLIC_KEY=pk-lf-...
   LANGFUSE_SECRET_KEY=sk-lf-...
   LANGFUSE_HOST=https://cloud.langfuse.com  # or your self-hosted URL
   ```

4. Restart Sure

All LLM operations will now be logged to Langfuse, including:
- Chat messages and responses
- Auto-categorization requests
- Merchant detection
- Token usage and costs
- Response times

### Langfuse Features in Sure

- **Automatic tracing:** Every LLM call is automatically traced
- **Session tracking:** Chat sessions are tracked with a unique session ID
- **User anonymization:** User IDs are hashed before sending to Langfuse
- **Cost tracking:** Token usage is logged for cost analysis
- **Error tracking:** Failed requests are logged with error details

### Viewing Traces

1. Go to your Langfuse dashboard
2. Navigate to **Traces**
3. You'll see traces for:
   - `openai.chat_response` - Chat assistant interactions
   - `openai.auto_categorize` - Transaction categorization
   - `openai.auto_detect_merchants` - Merchant detection

### Privacy Considerations

**What's sent to Langfuse:**
- Prompts and responses
- Model names
- Token counts
- Timestamps
- Session IDs
- Hashed user IDs (not actual user data)

**What's NOT sent:**
- User email addresses
- User names
- Unhashed user IDs
- Account credentials

**For maximum privacy:** Self-host Langfuse on your own infrastructure.

## Testing and Evaluation

### Manual Testing

Test your AI configuration:

1. Go to the Chat interface in Sure
2. Try these test prompts:
   - "Show me my total spending this month"
   - "What are my top 5 spending categories?"
   - "How much do I have in savings?"

3. Verify:
   - Responses are relevant
   - Function calls work (you should see "Analyzing your data..." briefly)
   - Numbers match your actual data

### Automated Evaluation

Sure doesn't currently include automated evals, but you can build them using Langfuse:

1. **Collect baseline responses:** Run test prompts and save responses
2. **Create evaluation dataset:** Use Langfuse datasets feature
3. **Run evaluations:** Test new models/prompts against the dataset
4. **Compare results:** Use Langfuse's comparison tools

### Benchmarking Models

To compare models for your use case:

1. **Speed Test:**
   - Send the same prompt to different models
   - Measure time to first token (TTFT)
   - Measure overall response time

2. **Quality Test:**
   - Create a set of 10-20 realistic financial questions
   - Get responses from each model
   - Manually rate accuracy and helpfulness

3. **Cost Test:**
   - Calculate cost per interaction based on token usage
   - Factor in your expected usage volume
   - Consider speed vs. cost tradeoffs

### Example Evaluation Queries

Good test queries that exercise different capabilities:

- **Simple retrieval:** "What's my checking account balance?"
- **Aggregation:** "Total spending on restaurants last month?"
- **Comparison:** "Am I spending more or less than last year?"
- **Analysis:** "What are my biggest expenses this quarter?"
- **Forecasting:** "Based on my spending, when will I reach $10k savings?"

## Cost Considerations

### Cloud Costs

Typical costs for OpenAI (as of early 2025):

- **gpt-4.1:** ~$5-15 per 1M input tokens, ~$15-60 per 1M output tokens
- **gpt-5:** ~2-3x more expensive than gpt-4.1
- **gpt-4o-mini:** ~$0.15 per 1M input tokens (very cheap)

**Typical usage:**
- Chat message: 500-2000 tokens (input) + 100-500 tokens (output)
- Auto-categorization: 1000-3000 tokens per 25 transactions
- Cost per chat message: $0.01-0.05 for gpt-4.1

**Optimization tips:**
1. Use `gpt-4o-mini` for categorization
2. Use Langfuse to identify expensive prompts
3. Cache results when possible
4. Consider local LLMs for high-volume operations

### Local Costs

**One-time costs:**
- GPU hardware: $500-2000+ depending on VRAM needs
- Setup time: 2-8 hours

**Ongoing costs:**
- Electricity: ~$0.10-0.50 per hour of GPU usage
- Maintenance: Occasional updates and monitoring

**Break-even analysis:**

If you process 10,000 messages/month:
- Cloud (gpt-4.1): ~$200-500/month
- Local (amortized): ~$50-100/month after hardware cost
- Break-even: 6-12 months depending on hardware cost

**Recommendation:** Start with cloud, switch to local if costs exceed $100-200/month.

### Hybrid Approach

You can mix providers:

```python
# Example: Use local for categorization, cloud for chat
# Categorization (high volume, lower complexity)
CATEGORIZATION_PROVIDER=ollama
CATEGORIZATION_MODEL=gemma2:7b

# Chat (lower volume, higher complexity)
CHAT_PROVIDER=openai
CHAT_MODEL=gpt-4.1
```

**Note:** Sure currently uses a single provider for all operations, but this could be customized.

## Troubleshooting

### "Messages is invalid" Error

**Symptom:** Cannot start a chat, see validation error

**Cause:** Using a custom provider (like Ollama) without setting `OPENAI_MODEL`

**Fix:**
```bash
# Make sure all three are set for custom providers
OPENAI_ACCESS_TOKEN=ollama-local  # Any non-empty value
OPENAI_URI_BASE=http://localhost:11434/v1
OPENAI_MODEL=your-model-name  # REQUIRED!
```

### Model Not Found

**Symptom:** Error about model not being available

**Cloud:** Check that you're using a valid model name for your provider

**Local:** Make sure you've pulled the model:
```bash
ollama list  # See what's installed
ollama pull model-name  # Install a model
```

### "Fixed prompt tokens exceed context budget"

**Symptom:** Auto-categorization or merchant detection fails immediately with an error like:

```text
Fixed prompt tokens (2108) exceed context budget (1280)
```

**Cause:** Sure computes the usable prompt budget as:

```text
context_window - max_response_tokens - system_prompt_reserve
```

The defaults are conservative:
- `LLM_CONTEXT_WINDOW=2048`
- `LLM_MAX_RESPONSE_TOKENS=512`
- `LLM_SYSTEM_PROMPT_RESERVE=256`

That leaves `1280` input tokens. On local or custom models, the fixed prompt can already exceed that budget once you include Sure's instructions, category taxonomy, and schema payloads.

**Fix:**
```bash
LLM_CONTEXT_WINDOW=8192
```

Then restart both `web` and `worker` so the new env var is loaded. If you are using Docker Compose, make sure your compose file forwards `LLM_CONTEXT_WINDOW` into the containers.

### Slow Responses

**Symptom:** Long wait times for AI responses

**Cloud:**
- Switch to a faster model (e.g., `gpt-4o-mini` or `gemini-2.0-flash-exp`)
- Check your internet connection
- Verify provider status page

**Local:**
- Check GPU utilization (should be near 100% during inference)
- Try a smaller model
- Ensure you're using GPU, not CPU
- Check for thermal throttling
- If you see `Net::ReadTimeout` after fixing the context budget, raise `OPENAI_REQUEST_TIMEOUT` (for example `180`)

### Chat Errors While the Model Is Still Generating

**Symptom:** The chat shows "Thinking…" for a while, then an error saying the assistant is not available — but the model does produce a reply and LLM Usage shows tokens were generated.

**Cause:** Three settings interact here, measured over different spans:

- `OPENAI_REQUEST_TIMEOUT` (default `60`) — applies to **each HTTP call** to the model, on its own.
- `ASSISTANT_MAX_TOOL_CALL_ITERATIONS` (default `8`) — how many chained tool-call rounds one turn may make. A turn costs up to `1 + this` model calls. On the final permitted round the model is offered no tools, so it answers in text instead of erroring.
- `AI_RESPONSE_TIMEOUT` (default `90`) — covers the **whole turn**, and its clock starts when the message is queued, so Sidekiq queue time counts against it.

Responses from custom OpenAI-compatible providers are **not streamed**, so nothing appears in the chat until the entire reply is generated. Worse, the assistant only shows text once a response actually contains some — a tool-call-only response produces nothing to display — so a turn that chains several tool calls sits on "Thinking…" through all of them. At the defaults the worst case is nine sequential model calls plus eight tool-round executions.

**Fix:** size `AI_RESPONSE_TIMEOUT` as a **sum**, not simply as a number larger than the per-call limit:

```text
AI_RESPONSE_TIMEOUT ≥ (1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) × OPENAI_REQUEST_TIMEOUT
                      + tool execution + queue wait
```

You have two levers, and the cheaper one is usually the tool-call cap, because it divides the first term. With `ASSISTANT_MAX_TOOL_CALL_ITERATIONS=2` a turn costs at most three model calls instead of nine, cutting the timeout you need by two-thirds. The trade-off is that genuinely long tool chains fail earlier, with a clear "exceeded the tool-call limit" error rather than a timeout.

```bash
OPENAI_REQUEST_TIMEOUT=300
ASSISTANT_MAX_TOOL_CALL_ITERATIONS=2
AI_RESPONSE_TIMEOUT=1200   # (1 + 2) × 300 = 900, plus 300 headroom
```

Keeping the full eight iterations at 300s per call would instead need `9 × 300 = 2700` plus headroom — which is why lowering the cap is usually the better trade.

If `AI_RESPONSE_TIMEOUT` ends up below what the turn actually takes, you get a generic "no response" instead of the specific timeout error, and the job keeps running and burning tokens after the chat has given up.

`AI_RESPONSE_TIMEOUT` can also be set at **Settings → Self-Hosting → OpenAI → Chat Response Timeout**, which takes effect without a restart. The environment variable wins if both are set. The minimum accepted value is `30`.

Restart `web` and `worker` after changing the environment variables, and make sure your Docker Compose file forwards them into the containers.

### No Provider Available

**Symptom:** "Provider not found" or similar error

**Fix:**
1. Check `OPENAI_ACCESS_TOKEN` is set
2. For custom providers, verify `OPENAI_URI_BASE` and `OPENAI_MODEL`
3. Restart Sure after changing environment variables
4. Check logs for specific error messages

### "Failed to generate response" with External Assistant

**Symptom:** Chat shows "Failed to generate response" when expecting the external assistant

**Check in order:**

1. **Is external routing active?** Sure uses external mode when `ASSISTANT_TYPE=external` is set as an env var, OR when the family's `assistant_type` is set to "external" in Settings. Check what the pod sees:
   ```bash
   kubectl exec deploy/sure-web -c rails -- env | grep ASSISTANT_TYPE
   kubectl exec deploy/sure-worker -c sidekiq -- env | grep ASSISTANT_TYPE
   ```
   If the env var is unset, check the family setting in the database or Settings UI.

2. **Can Sure reach the agent?** Test from inside the worker pod (use `sh -c` so the env var expands inside the pod, not locally):
   ```bash
   kubectl exec deploy/sure-worker -c sidekiq -- \
     sh -c 'curl -s -o /dev/null -w "%{http_code}" \
     -H "Authorization: Bearer $EXTERNAL_ASSISTANT_TOKEN" \
     -H "Content-Type: application/json" \
     -d "{\"model\":\"test\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}" \
     $EXTERNAL_ASSISTANT_URL'
   ```
   - **Exit code 7 (connection refused):** Network policy is blocking. Check egress rules, and remember to use the `targetPort`, not the service port.
   - **HTTP 401/403:** Token mismatch between Sure's `EXTERNAL_ASSISTANT_TOKEN` and the agent's expected token.
   - **HTTP 404:** Wrong URL path. Must be `/v1/chat/completions`.

3. **Check worker logs** for the actual error:
   ```bash
   kubectl logs deploy/sure-worker -c sidekiq --tail=50 | grep -i "external\|assistant\|error"
   ```

4. **If using Pipelock:** Check the Pipelock deployment logs. A crashed Pipelock proxy can block outbound requests:
   ```bash
   kubectl logs deploy/sure-pipelock --tail=20
   ```

### High Costs

**Symptom:** Unexpected bills from cloud provider

**Analysis:**
1. Check Langfuse for usage patterns
2. Look for unusually long conversations
3. Check if you're using an expensive model

**Optimization:**
1. Switch to cheaper model for categorization
2. Consider local LLM for high-volume tasks
3. Implement rate limiting if needed
4. Review and optimize system prompts

## Advanced Topics

### Custom System Prompts

The builtin AI assistant uses a system prompt that defines its behavior. The prompt is defined in `app/models/assistant/configurable.rb`. This does not apply to external assistants, which manage their own prompts.

The prompt has two halves: `STATIC_INSTRUCTIONS`, a frozen constant that is
byte-identical on every request (providers cache and discount an
exactly-repeated prefix), and a trailing `## Session context` block holding
everything volatile (date, currency, account roster, categories).

To customize:
1. Fork the repository
2. Edit the `STATIC_INSTRUCTIONS` constant (keep customizations there so the
   prompt stays cacheable; only put genuinely per-request data in the session
   context builders)
3. Rebuild and deploy

**What you can customize:**
- Tone and personality
- Response format
- Rules and constraints
- Domain expertise

### Function Calling

The assistant uses OpenAI's function calling (tool use) to access user data:

**Available functions:**

Read and analysis:
- `get_transactions` - Search transactions with filters, sorting and pagination
- `get_recurring_transactions` - Detected and manual recurring transactions (subscriptions, bills) with totals
- `get_accounts` - Accounts with ids and current balances; opt-in balance history series
- `get_holdings` - Investment holdings
- `get_balance_sheet` - Net worth, assets and liabilities with a configurable history period and interval
- `get_income_statement` - Income and expenses for a period, with monthly series, prior-period comparison and account filtering
- `get_budget` - Budget summary for a month
- `get_merchants` - Merchants with the ids update_transaction accepts and the exact names get_transactions filters on
- `get_tags` / `get_categories` - Tag and category listings with pagination

Write:
- `update_transaction`, `update_budget`, `create_goal`
- `create_tag` / `update_tag`, `create_category` / `update_category`

Documents:
- `import_bank_statement` - Import bank statement data
- `search_family_files` - Search uploaded documents (vector store)

These are defined in `app/models/assistant/function/`. Preview tools
(Statement Vault, `get_valuations`, `get_insights`) are listed in
[mcp.md](mcp.md#preview-tools).

### Vector Store (Document Search)

Sure's AI assistant can search documents that have been uploaded to a family's vault. Under the hood, documents are indexed in a **vector store** so the assistant can retrieve relevant passages when answering questions (Retrieval-Augmented Generation).

#### How It Works

1. When a user uploads a document to their family vault, it is automatically pushed to the configured vector store.
2. When the assistant needs financial context from uploaded files, it calls the `search_family_files` function.
3. The vector store returns the most relevant passages, which the assistant uses to answer the question.

#### Supported Backends

| Backend | Status | Best For | Requirements |
|---------|--------|----------|--------------|
| **OpenAI** (default) | ready | Cloud deployments, zero setup | `OPENAI_ACCESS_TOKEN` |
| **Pgvector** | ready | Self-hosted, full data privacy | PostgreSQL with `pgvector` extension + embedding model |
| **Qdrant** | scaffolded | Self-hosted, dedicated vector DB | Running Qdrant instance |

#### Configuration

##### OpenAI (Default)

No extra configuration is needed. If you already have `OPENAI_ACCESS_TOKEN` set for the AI assistant, document search works automatically. OpenAI manages chunking, embedding, and retrieval.

```bash
# Already set for AI chat - document search uses the same token
OPENAI_ACCESS_TOKEN=sk-proj-...
```

##### Pgvector (Self-Hosted)

Use PostgreSQL's pgvector extension for fully local document search. All data stays on your infrastructure.

**Requirements:**
- Use the `pgvector/pgvector:pg16` Docker image instead of `postgres:16` (drop-in replacement)
- An embedding model served via an OpenAI-compatible `/v1/embeddings` endpoint (e.g. Ollama with `nomic-embed-text`)
- Run the migration with `VECTOR_STORE_PROVIDER=pgvector` to create the `vector_store_chunks` table

```bash
# Required
VECTOR_STORE_PROVIDER=pgvector

# Embedding model configuration
EMBEDDING_MODEL=nomic-embed-text          # Default: nomic-embed-text
EMBEDDING_DIMENSIONS=1024                 # Default: 1024 (must match your model)
EMBEDDING_URI_BASE=http://ollama:11434/v1 # Falls back to OPENAI_URI_BASE if not set
EMBEDDING_ACCESS_TOKEN=                   # Falls back to OPENAI_ACCESS_TOKEN if not set
```

If you are using Ollama (as in `compose.example.ai.yml`), pull the embedding model:

```bash
docker compose exec ollama ollama pull nomic-embed-text
```

##### Qdrant (Self-Hosted)

> [!CAUTION]
> Only `OpenAI` has been implemented!

Use a dedicated Qdrant vector database:

```bash
VECTOR_STORE_PROVIDER=qdrant
QDRANT_URL=http://localhost:6333   # Default if not set
QDRANT_API_KEY=your-api-key        # Optional, for authenticated instances
```

Docker Compose example:

```yaml
services:
  sure:
    environment:
      - VECTOR_STORE_PROVIDER=qdrant
      - QDRANT_URL=http://qdrant:6333
    depends_on:
      - qdrant

  qdrant:
    image: qdrant/qdrant:latest
    ports:
      - "6333:6333"
    volumes:
      - qdrant_data:/qdrant/storage

volumes:
  qdrant_data:
```

> **Note:** The Qdrant adapter is currently a skeleton. A future release will add full support including collection management and embedding configuration.

#### Verifying the Configuration

You can check whether a vector store is properly configured from the Rails console:

```ruby
VectorStore.configured?          # => true / false
VectorStore.adapter              # => #<VectorStore::Openai:...>
VectorStore.adapter.class.name   # => "VectorStore::Openai"
```

#### Supported File Types

The following file extensions are supported for document upload and search:

`.pdf`, `.txt`, `.md`, `.csv`, `.json`, `.xml`, `.html`, `.css`, `.js`, `.rb`, `.py`, `.docx`, `.pptx`, `.xlsx`, `.yaml`, `.yml`, `.log`, `.sh`

#### Privacy Notes

- **OpenAI backend:** Document content is sent to OpenAI's API for indexing and search. The same privacy considerations as the AI chat apply.
- **Pgvector / Qdrant backends:** All data stays on your infrastructure. No external API calls are made for document search.

### Multi-Model Setup

Currently not supported out of the box, but you could:
1. Create multiple provider instances
2. Add routing logic to select provider based on task
3. Update controllers to specify which provider to use

### Rate Limiting

To prevent abuse or runaway costs:

1. Use [Rack::Attack](https://github.com/rack/rack-attack) (already included)
2. Configure in `config/initializers/rack_attack.rb`
3. Limit requests per user or globally

Example:
```ruby
# Limit chat creation to 10 per minute per user
throttle('chats/create', limit: 10, period: 1.minute) do |req|
  req.session[:user_id] if req.path == '/chats' && req.post?
end
```

## Resources

- [OpenAI Documentation](https://platform.openai.com/docs)
- [Ollama Documentation](https://github.com/ollama/ollama)
- [OpenRouter Documentation](https://openrouter.ai/docs)
- [Langfuse Documentation](https://langfuse.com/docs)
- [Sure GitHub Repository](https://github.com/we-promise/sure)

## Support

For issues with AI features:
1. Check this documentation first
2. Search [existing GitHub issues](https://github.com/we-promise/sure/issues)
3. Open a new issue with:
   - Your configuration (redact API keys!)
   - Error messages
   - Steps to reproduce
   - Expected vs. actual behavior

---

**Last Updated:** March 2026

---

## File: docs/hosting/docker.md

# Self Hosting Sure with Docker

This guide will help you setup, update, and maintain your self-hosted Sure application with Docker Compose. Docker Compose is the most popular and recommended way to self-host the Sure app.

## Setup Guide

Follow the guide below to get your app running.

### Step 1: Install Docker

Complete the following steps:

1. Install Docker Engine by following [the official guide](https://docs.docker.com/engine/install/)
2. Start the Docker service on your machine
3. Verify that Docker is installed correctly and is running by opening up a terminal and running the following command:

```bash
# If Docker is setup correctly, this command will succeed
docker run hello-world
```

### Step 2: Configure your Docker Compose file and environment

#### Create a directory for your app to run

Open your terminal and create a directory where your app will run. Below is an example command with a recommended directory:

```bash
# Create a directory on your computer for Docker files (name it whatever you like)
mkdir -p ~/docker-apps/sure

# Once created, navigate your current working directory to the new folder
cd ~/docker-apps/sure
```

#### Copy our sample Docker Compose file

Make sure you are in the directory you just created and run the following command:

```bash
# Download the sample compose.yml file from the GitHub repository
curl -o compose.yml https://raw.githubusercontent.com/we-promise/sure/main/compose.example.yml
```

This command will do the following:

1. Fetch the sample docker compose file from our public Github repository
2. Creates a file in your current directory called `compose.yml` with the contents of the example file

At this point, the only file in your current working directory should be `compose.yml`.

### Step 3 (optional): Configure your environment

By default, our `compose.example.yml` file runs without any configuration.  
That said, if you would like extra security (important if you're running outside of a local network), you can follow the steps below to set things up.

If you're running the app locally and don't care much about security, you can skip this step.

#### Create your environment file

In order to configure the app, you will need to create a file called `.env`, which is where Docker will read environment variables from.

To do this, you should get our .env.example as a starting point:

```bash
curl -o .env https://raw.githubusercontent.com/we-promise/sure/main/.env.example
```

#### Generate the app secret key

The app requires an environment variable called `SECRET_KEY_BASE` to run.

We will first need to generate this in the terminal. If you have `openssl` installed on your computer, you can generate it with the following command:

```bash
openssl rand -hex 64
```

_Alternatively_, you can generate a key without openssl or any external dependencies by pasting the following bash command in your terminal and running it:

```bash
head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n' && echo
```

Once you have generated a key, save it and move on to the next step.

#### Fill in your environment file

Open the file named `.env` that we created in a prior step using your favorite text editor.

Fill in this file with the following variables:

```txt
SECRET_KEY_BASE="replacemewiththegeneratedstringfromthepriorstep"
POSTGRES_PASSWORD="replacemewithyourdesireddatabasepassword"
```

#### Using HTTPS

Assuming you want to access your instance from the internet, you should have secured your URL address with an SSL certificate.  
The Docker instance runs in plain HTTP and you need to tell it that you are redirecting your HTTPS stream to the HTTP one.  
To do this, edit the `compose.yml` file and find the line stating:  

```yaml
RAILS_ASSUME_SSL: "false"
```

and change it to `true`

```yaml
RAILS_ASSUME_SSL: "true"
```

#### WebAuthn MFA (passkeys and security keys)

If you enable passkeys, Touch ID, Windows Hello, or hardware security keys as MFA credentials, pin the WebAuthn relying party settings in your `.env` file:

```txt
WEBAUTHN_RP_ID="example.com"
WEBAUTHN_ALLOWED_ORIGINS="https://sure.example.com"
```

`WEBAUTHN_RP_ID` should usually be your registrable domain, not a full URL. See [WebAuthn MFA Configuration](webauthn.md) before changing hostnames or reverse proxy settings for an instance with registered passkeys.

#### Binding to IPv6 (optional)

By default Sure listens on `0.0.0.0:3000` (IPv4 wildcard) inside the container and Docker publishes the port on the host's IPv4 interface only. If you want the app reachable over IPv6 as well, two things need to change:

1. **Tell the app to bind to `[::]`** by setting `BINDING=::` in the container environment. `BINDING` is Rails' native env var for the server bind address. On any kernel with `net.ipv6.bindv6only=0` (the default on Linux and macOS) a single `[::]` bind is **dual-stack**: it accepts both IPv6 and IPv4 clients from the same socket. You do not need two binds and you do not need two ports.
2. **Tell Docker to publish the host port on IPv6** by adding a bracketed-host `ports:` entry alongside the existing IPv4 one.

In `compose.yml`:

```yaml
services:
  web:
    ports:
      - ${PORT:-3000}:3000
      - "[::]:${PORT:-3000}:3000"
    environment:
      <<: *rails_env
      BINDING: "::"
```

With both changes in place, `http://127.0.0.1:3000/` and `http://[::1]:3000/` both work against the same container.

**Note:** Docker's default userland proxy already bridges host-side IPv6 publishes to the container's internal IPv4 address, so in many setups just adding the `[::]:` port entry is enough. Setting `BINDING=::` inside the container only becomes load-bearing when the Docker daemon has `"ipv6": true` + `"ip6tables": true` configured (uncommon for self-hosters) and forwards raw IPv6 packets into the container via netfilter instead of the proxy. Setting both is harmless and future-proof.

If you are running behind a reverse proxy that terminates TLS, nothing else changes — `proxy_pass http://[::1]:3000` and `proxy_pass http://127.0.0.1:3000` both work because the `[::]` bind is dual-stack.

#### Local development bind

For `bin/dev` on your own machine, the server now defaults to Rails' native `localhost` bind (`127.0.0.1` + `[::1]`) — only reachable from the same machine. If you need external access (phone on the same WiFi, devcontainer port forwarding, LAN testing), set the Rails-native env var:

```bash
BINDING=0.0.0.0 bin/dev   # reachable from LAN
BINDING=::       bin/dev  # IPv6 dual-stack
```

The bundled devcontainer at `.devcontainer/docker-compose.yml` already pins `BINDING: "0.0.0.0"` so Docker port forwarding reaches the app — no manual override needed when using the devcontainer.

### Step 4: Run the app

You are now ready to run the app. Start with the following command to make sure everything is working:

```bash
docker compose up
```

This will pull our official Docker image and start the app. You will see logs in your terminal.

Open your browser, and navigate to `http://localhost:3000`.

If everything is working, you will see the Sure login screen.

### Step 5: Create your account

The first time you run the app, you will need to register a new account by hitting "create your account" on the login page.

1. Enter your email
2. Enter a password

### Step 5a: Restrict future signups (optional)

After creating your initial admin account, you can control how other people join your self-hosted instance from **Settings > Self-Hosting > Onboarding**.

- **Open**: Anyone can create an account from the registration page.
- **Invite-only**: New account creation stays enabled. Signups require a valid invite code unless you configure a default family for invite-only onboarding.
- **Closed**: The registration page is disabled for new signups.

If you do not want additional self-service registrations, switch the instance to **Closed** after the initial setup.

### Step 6: Run the app in the background

Most self-hosting users will want the Sure app to run in the background on their computer so they can access it at all times. To do this, hit `Ctrl+C` to stop the running process, and then run the following command:

```bash
docker compose up -d
```

The `-d` flag will run Docker Compose in "detached" mode. To verify it is running, you can run the following command:

```
docker compose ls
```

### Step 7: Enjoy!

Your app is now set up. You can visit it at `http://localhost:3000` in your browser.

If you find bugs or have a feature request, be sure to read through our [contributing guide here](https://github.com/we-promise/sure/wiki/How-to-Contribute-Effectively-to-Sure).

## AI features, external assistant, and Pipelock

Sure ships with a separate compose file for AI-related features: `compose.example.ai.yml`. It adds:

- **Pipelock** (always on): AI agent security proxy that scans outbound LLM calls and inbound MCP traffic
- **Ollama + Open WebUI** (optional `--profile ai`): local LLM inference

### Using the AI compose file

```bash
# Download both compose files
curl -o compose.yml https://raw.githubusercontent.com/we-promise/sure/main/compose.example.yml
curl -o compose.ai.yml https://raw.githubusercontent.com/we-promise/sure/main/compose.example.ai.yml
curl -o pipelock.example.yaml https://raw.githubusercontent.com/we-promise/sure/main/pipelock.example.yaml

# Run with Pipelock (no local LLM)
docker compose -f compose.ai.yml up -d

# Run with Pipelock + Ollama
docker compose -f compose.ai.yml --profile ai up -d
```

### Setting up the external AI assistant

The external assistant delegates chat to a remote AI agent instead of calling LLMs directly. The agent calls back to Sure's `/mcp` endpoint for financial data (accounts, transactions, balance sheet).

1. Set the MCP endpoint credentials in your `.env`:
   ```bash
   MCP_API_TOKEN=generate-a-random-token-here
   MCP_USER_EMAIL=your@email.com   # must match an existing Sure user
   ```

2. Set the external assistant connection:
   ```bash
   EXTERNAL_ASSISTANT_URL=https://your-agent/v1/chat/completions
   EXTERNAL_ASSISTANT_TOKEN=your-agent-api-token
   ```

3. Choose how to activate:
   - **Per-family (UI):** Go to Settings > Self-Hosting > AI Assistant, select "External"
   - **Global (env):** Set `ASSISTANT_TYPE=external` to force all families to use external

See [docs/hosting/ai.md](ai.md) for full configuration details including agent ID, session keys, and email allowlisting.

### Pipelock security proxy

Pipelock sits between Sure and external services, scanning AI traffic for:

- **Secret exfiltration** (DLP): catches API keys, tokens, or personal data leaking in prompts
- **Prompt injection**: detects attempts to override system instructions
- **Tool poisoning**: validates MCP tool calls against known-safe patterns
- **Signed receipts**: optional hash-chained evidence of mediated decisions when `flight_recorder.dir` and `signing_key_path` are configured

When using `compose.example.ai.yml`, Pipelock is always running. External AI agents should connect to port 8889 (MCP reverse proxy) instead of directly to Sure's `/mcp` on port 3000.

For full Pipelock configuration, see [docs/hosting/pipelock.md](pipelock.md).

## How to update your app

The mechanism that updates your self-hosted Sure app is the GHCR (Github Container Registry) Docker image that you see in the `compose.yml` file:

```yml
image: ghcr.io/we-promise/sure:latest
```

We recommend using one of the following images, but you can pin your app to whatever version you'd like (see [packages](https://github.com/we-promise/sure/pkgs/container/sure)):

- `ghcr.io/we-promise/sure:latest` (latest `alpha`)
- `ghcr.io/we-promise/sure:stable` (latest release)

By default, your app _will NOT_ automatically update. To update your self-hosted app, run the following commands in your terminal:

```bash
cd ~/docker-apps/sure # Navigate to whatever directory you configured the app in
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
```

## How to change which updates your app receives

If you'd like to pin the app to a specific version or tag, all you need to do is edit the `compose.yml` file:

```yml
image: ghcr.io/we-promise/sure:stable
```

After doing this, make sure and restart the app:

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

### Inspecting background jobs (`/sidekiq`)

Sure ships the Sidekiq Web dashboard at `/sidekiq`. The route only exists for a signed-in **super admin** — the first user created on your instance. Anyone else (including logged-out visitors) gets a 404, so there is nothing to configure to keep it safe. If you want a second layer of protection anyway, set both `SIDEKIQ_WEB_USERNAME` and `SIDEKIQ_WEB_PASSWORD` in your environment file to additionally require basic-auth credentials; there are no default credentials.

For day-to-day triage of stuck syncs, imports, and exports, prefer **Settings → Background jobs** — it maps queue state onto the actual records and offers safe recovery actions. The Sidekiq dashboard is a break-glass tool; two warnings when using it directly:

- Never manually retry `SimplefinConnectionUpdateJob` — it consumes a single-use setup token, and a retry permanently breaks that connection attempt.
- Deleting or retrying jobs does **not** update the corresponding Sure record (a deleted `ImportJob` leaves its import stuck in `importing`) — use Settings → Background jobs for record-level recovery.

---

## File: docs/hosting/hetzner.md

# Deploying Sure on Hetzner Cloud

This guide will help you deploy Sure on a Hetzner Cloud server with Docker Compose, including SSL certificates, security hardening, and automated backups.

## Prerequisites

Before starting, ensure you have:

- A Hetzner Cloud server (recommended: 4GB RAM, 2 CPU cores minimum)
- A domain name pointing to your server's IP address
- SSH access to your server
- Basic familiarity with Linux command line

## Step 1: Server Setup and Security

Connect to your Hetzner server and set up the basic environment:

```bash
# Connect to your server (replace with your server's IP)
ssh root@YOUR_SERVER_IP

# Update the system
apt update && apt upgrade -y

# Install essential packages
apt install -y curl wget git ufw fail2ban

# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh

# Install Docker Compose
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose

# Add your user to docker group (replace 'your_username' with your actual username)
# If you are using root, you can skip this step
if [ "$(whoami)" != "root" ]; then
    usermod -aG docker "$(whoami)"
fi

# Configure firewall
ufw allow ssh
ufw allow 80
ufw allow 443
ufw --force enable

# Configure fail2ban for SSH protection
systemctl enable fail2ban
systemctl start fail2ban
```

## Step 2: Create Application Directory

```bash
# Create directory for the application
mkdir -p /opt/sure
cd /opt/sure

# Download the Docker Compose configuration
curl -o compose.yml https://raw.githubusercontent.com/we-promise/sure/main/compose.example.yml
```

## Step 3: Configure Environment Variables

Create a secure environment configuration:

```bash
# Create environment file
nano .env
```

Add the following content to the `.env` file (replace the values with your own):

```bash
# Generate a secure secret key
SECRET_KEY_BASE="$(openssl rand -hex 64)"

# Database configuration
POSTGRES_USER="sure_user"
POSTGRES_PASSWORD="$(openssl rand -base64 32)"
POSTGRES_DB="sure_production"

# Optional: OpenAI integration (add your API key if you want AI features)
# OPENAI_ACCESS_TOKEN="your_openai_api_key_here"
```

**Important Security Notes:**
- Never use the default values from the example file in production
- Keep your `.env` file secure and never commit it to version control
- The `SECRET_KEY_BASE` is critical for Rails security - keep it secret

## Step 4: Set Up Reverse Proxy with SSL

We'll use Nginx as a reverse proxy with Let's Encrypt SSL certificates:

```bash
# Install Nginx and Certbot
apt install -y nginx certbot python3-certbot-nginx

# Create Nginx configuration for your domain
nano /etc/nginx/sites-available/sure
```

Add this Nginx configuration (replace `yourdomain.com` with your actual domain):

```nginx
server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Port $server_port;
    }
}
```

```bash
# Enable the site
ln -s /etc/nginx/sites-available/sure /etc/nginx/sites-enabled/
rm /etc/nginx/sites-enabled/default

# Test Nginx configuration
nginx -t

# Start Nginx
systemctl enable nginx
systemctl start nginx

# Get SSL certificate
certbot --nginx -d yourdomain.com -d www.yourdomain.com
```

## Step 5: Deploy the Application

Now let's deploy the Sure application:

```bash
# Navigate to the application directory
cd /opt/sure

# Pull the latest Docker images
docker compose pull

# Start the application
docker compose up -d

# Check if everything is running
docker compose ps

# View logs to ensure everything started correctly
docker compose logs -f
```

## Step 6: Test the Deployment

Verify your deployment is working:

```bash
# Check if the application is accessible
curl -I https://yourdomain.com

# Check Docker container health
docker compose ps
```

Now you can:

1. **Visit your application**: Go to `https://yourdomain.com` in your browser
2. **Create your admin account**: Click "Create your account" on the login page
3. **Set up your first family**: Follow the onboarding process

### Optional: Disable self-service registration

Once your initial admin account is ready, open **Settings > Self-Hosting > Onboarding** to choose how signups should work:

- **Open**: Anyone can register.
- **Invite-only**: New signups require a valid invite code unless you configure a default family for invite-only onboarding.
- **Closed**: New signups from the registration page are blocked.

For single-admin or tightly controlled deployments, set the onboarding mode to **Closed** after the initial setup.

## Step 7: Set Up Automated Backups

Create a backup script to protect your data:

```bash
# Create backup script
nano /opt/sure/backup.sh
```

Add this backup script:

```bash
#!/bin/bash
BACKUP_DIR="/opt/sure/backups"
DATE=$(date +%Y%m%d_%H%M%S)

# Create backup directory
mkdir -p $BACKUP_DIR

# Backup database
docker compose exec -T db pg_dump -U sure_user sure_production > $BACKUP_DIR/db_backup_$DATE.sql

# Backup application data
docker compose exec -T web tar -czf - /rails/storage > $BACKUP_DIR/storage_backup_$DATE.tar.gz

# Keep only last 7 days of backups
find $BACKUP_DIR -name "*.sql" -mtime +7 -delete
find $BACKUP_DIR -name "*.tar.gz" -mtime +7 -delete

echo "Backup completed: $DATE"
```

```bash
# Make backup script executable
chmod +x /opt/sure/backup.sh

# Add to crontab for daily backups at 2 AM
crontab -e
```

Add this line to crontab:
```bash
0 2 * * * /opt/sure/backup.sh >> /var/log/sure-backup.log 2>&1
```

## Step 8: Set Up Basic Monitoring

Create a health check script to monitor your application:

```bash
# Install htop for system monitoring
apt install -y htop

# Create a simple health check script
nano /opt/sure/health-check.sh
```

Add this health check script:

```bash
#!/bin/bash
# Check if the application is responding
if curl -f -s https://yourdomain.com > /dev/null; then
    echo "$(date): Application is healthy"
else
    echo "$(date): Application is down - restarting"
    cd /opt/sure
    docker compose restart web worker
fi
```

```bash
# Make health check executable
chmod +x /opt/sure/health-check.sh

# Add to crontab to run every 5 minutes
crontab -e
```

Add this line:
```bash
*/5 * * * * /opt/sure/health-check.sh >> /var/log/sure-health.log 2>&1
```

## Maintenance Commands

Here are the essential commands for maintaining your deployment:

### Update the application:
```bash
cd /opt/sure
docker compose pull
docker compose up --no-deps -d web worker
```

### View logs:
```bash
# All services
docker compose logs -f

# Specific service
docker compose logs -f web
docker compose logs -f worker
docker compose logs -f db
```

### Restart services:
```bash
# Restart all services
docker compose restart

# Restart specific service
docker compose restart web
```

### Check system resources:
```bash
# Docker resource usage
docker stats

# System resources
htop
df -h
```

### Restore from backup:
```bash
# Restore database
docker compose exec -T db psql -U sure_user sure_production < /opt/sure/backups/db_backup_YYYYMMDD_HHMMSS.sql

# Restore application data
docker compose exec -T web tar -xzf /opt/sure/backups/storage_backup_YYYYMMDD_HHMMSS.tar.gz -C /
```

## Security Features

Your deployment includes several security measures:

1. **Firewall**: Only ports 22 (SSH), 80 (HTTP), and 443 (HTTPS) are open
2. **Fail2ban**: Protects against brute force attacks on SSH
3. **SSL/TLS**: Automatic HTTPS with Let's Encrypt certificates
4. **Environment variables**: Sensitive data stored securely in `.env` file
5. **Non-root containers**: Application runs as non-root user
6. **Regular updates**: Keep your system and Docker images updated

## Troubleshooting

### Common Issues and Solutions

**Application won't start:**
```bash
# Check logs for errors
docker compose logs -f

# Check if ports are available
netstat -tulpn | grep :3000
```

**Database connection issues:**
```bash
# Check database container
docker compose logs db

# Test database connection
docker compose exec db psql -U sure_user -d sure_production -c "SELECT 1;"
```

**SSL certificate issues:**
```bash
# Renew certificates
certbot renew --dry-run

# Check certificate status
certbot certificates
```

**Out of disk space:**
```bash
# Check disk usage
df -h

# Clean up Docker images
docker system prune -a

# Clean up old backups
find /opt/sure/backups -name "*.sql" -mtime +7 -delete
```

**Application is slow:**
```bash
# Check system resources
htop
docker stats

# Check if containers are healthy
docker compose ps
```

## Performance Optimization

For better performance on Hetzner Cloud:

1. **Use SSD storage**: Hetzner Cloud provides NVMe SSD storage by default
2. **Choose appropriate server size**: 
   - Minimum: CX21 (2 vCPU, 4GB RAM)
   - Recommended: CX31 (2 vCPU, 8GB RAM) for multiple users
3. **Enable swap** (if needed):
   ```bash
   fallocate -l 2G /swapfile
   chmod 600 /swapfile
   mkswap /swapfile
   swapon /swapfile
   echo '/swapfile none swap sw 0 0' >> /etc/fstab
   ```

## Backup Strategy

Your backup strategy includes:

1. **Daily automated backups** of database and application data
2. **7-day retention** of backup files
3. **Separate backup directory** at `/opt/sure/backups`
4. **Logging** of backup operations

Consider additional backup options:
- **Off-site backups**: Copy backups to external storage (AWS S3, Google Cloud, etc.)
- **Database replication**: Set up PostgreSQL streaming replication
- **Snapshot backups**: Use Hetzner Cloud snapshots for full system backups

## Next Steps

After successful deployment:

1. **Create your admin account** at `https://yourdomain.com`
2. **Set up your first family** in the application
3. **Configure bank connections** (if using Plaid integration)
4. **Set up additional users** as needed
5. **Monitor your deployment** using the health check logs

## Support

If you encounter issues:

1. Check the [troubleshooting section](#troubleshooting) above
2. Review the application logs: `docker compose logs -f`
3. Check system resources: `htop` and `df -h`
4. Open a discussion in our [GitHub repository](https://github.com/we-promise/sure/discussions)

## Security Reminders

- Keep your server updated: `apt update && apt upgrade`
- Monitor your logs regularly: `/var/log/sure-backup.log` and `/var/log/sure-health.log`
- Use strong passwords for all accounts
- Consider setting up SSH key authentication instead of password authentication
- Regularly review your firewall rules: `ufw status`
- Monitor your SSL certificate expiration: `certbot certificates`

## 2. Official Technical Reference & Guides (we-promise/docs)

## File: README.md

# Sure Docs

Still `#needsupdate`!

This is the starter kit to get your docs deployed and ready to customize.

Click the green **Use this template** button at the top of this repo to copy the Mintlify starter kit. The starter kit contains examples with

- Guide pages
- Navigation
- Customizations
- API reference pages
- Use of popular components

[**Follow the full quickstart guide**](https://starter.mintlify.com/quickstart)

## Development

Install the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview your documentation changes locally. To install, use the following command:

```
npm i mint
```

Run the following command at the root of your documentation, where your `docs.json` is located:

```
mint dev
```

View your local preview at `http://localhost:3000`.

## Publishing changes

Install our GitHub app from your [dashboard](https://dashboard.mintlify.com/settings/organization/github-app) to propagate changes from your repo to your deployment. Changes are deployed to production automatically after pushing to the default branch.

## Need help?

### Troubleshooting

- If your dev environment isn't running: Run `mint update` to ensure you have the most recent version of the CLI.
- If a page loads as a 404: Make sure you are running in a folder with a valid `docs.json`.

### Resources

- [Mintlify documentation](https://mintlify.com/docs)

---

## File: guides/knowledge-base/faq.mdx

---
title: "FAQ"
---

## Transfers and Currencies

<AccordionGroup>
  <Accordion title="Can I have a transfer between accounts with different currencies (exchange rate)?">
    Yes, you can make transfers between accounts with different currencies. The system automatically applies the appropriate exchange rate for the transfer date:
    
    - The outgoing transaction uses the source account's currency and amount
    - The incoming transaction is converted to the destination account's currency using the latest available rate
    - If no exchange rate is found, it falls back to a 1:1 rate, which may result in incorrect amounts until rates are synced
    - Foreign exchange metadata is also recorded for auditing purposes
    
    For best results, make sure your exchange rates are up to date to avoid conversion errors.
    
    **Sources:**
    - [Transfer Creator Implementation](https://github.com/we-promise/sure/blob/8e36c8e7368cef008133970404bcaabd6cde3df9/app/models/transfer/creator.rb#L1-L103)
    - [Issue #124](https://github.com/we-promise/sure/issues/124)
  </Accordion>
</AccordionGroup>

---

## File: guides/knowledge-base/troubleshooting.mdx

---
title: "Troubleshooting"
---


## When Rules Are Triggered

Rules run automatically during family syncs or can be triggered manually. Understanding when rules execute helps troubleshoot categorization issues.

<AccordionGroup>
  <Accordion title="Manual trigger (user-initiated)">
    Rules can be manually triggered in the following ways:
    
    - Clicking "Re-apply" on an individual rule in `/settings/rules`
    - Clicking "Apply All" in `/settings/rules`
    - Running the rake task: `bin/rails rules:apply_all[family_id]`
  </Accordion>
  
  <Accordion title="During family sync (only active rules)">
    Rules with `active: true` run automatically when a family sync occurs (`Family::Syncer.perform_sync`):
    
    **Scheduled syncs:**
    - `SyncAllJob` - runs daily at 2:22 AM for all families
    - `SyncHourlyJob` - runs every hour (for items that opt-in to hourly syncing)
    
    **Triggered syncs:**
    - Provider webhooks (Plaid, etc.) - triggers sync which eventually propagates to family
    - Manual sync button on accounts page
    - After CSV imports complete (`Import` model calls `family.sync_later`)
  </Accordion>
  
  <Accordion title="When rules do NOT run">
    Rules are **not** triggered in these scenarios:
    
    - **Manual transaction creation** - Only triggers an Account sync (not Family sync)
    - **Manual transaction editing** - Same as above, only Account sync runs
    - **Inactive rules** - Rules with `active: false` never run automatically, only via manual "Re-apply"
  </Accordion>
</AccordionGroup>

## Category Rule Popup Behavior

When a user selects a category for a transaction, a popup may appear to create an automatic categorization rule. This popup is intentionally gated by several conditions:

<AccordionGroup>
  <Accordion title="When does the popup appear?">
    The popup only shows when **all** of these conditions are met:
    
    1. User has not disabled rule prompts (`rule_prompts_disabled` is false)
    2. User hasn't dismissed the popup in the last **24 hours**
    3. The transaction category actually changed
    4. No existing rule already sets this category for similar transactions
    5. The transaction has a category assigned
  </Accordion>
  
  <Accordion title="Why isn't the popup showing?">
    Check these common reasons:
    
    - **Recently dismissed**: Wait 24 hours after dismissing the popup
    - **Rule already exists**: A matching rule may already be in place
    - **Prompts disabled**: Check if `rule_prompts_disabled` is enabled for the user
  </Accordion>
</AccordionGroup>

## Sure Doesn’t Work Over HTTPS

If Sure behaves incorrectly or fails when accessed over HTTPS, it’s usually due to missing SSL-related configuration between Nginx and Rails.

<AccordionGroup>

  <Accordion title="What causes this issue?">

    Sure relies on correct protocol headers to determine whether a request is secure.  
    When HTTPS is terminated at Nginx but not properly forwarded to the app, Rails may treat requests as HTTP.

  </Accordion>

  <Accordion title="How do I fix it?">

    Apply the following configuration changes:

    1. **Nginx**

       Ensure HTTPS is forwarded to the upstream app:

       ```nginx
       proxy_set_header X-Forwarded-Proto https;
       ```

    2. **docker-compose.yml**

       In the `x-rails-env: &rails_env` section, set:

       ```yaml
       RAILS_ASSUME_SSL: "true"
       ```

       This tells Rails to treat all requests as HTTPS.

  </Accordion>

</AccordionGroup>


## Some pages break over HTTPS

Sure uses WebSockets for certain pages. If some pages fail to render correctly or break entirely, this is often caused by missing SSL or upgrade headers in your reverse proxy configuration.

<AccordionGroup>

  <Accordion title="What are the symptoms?">

    You may see pages partially load, fail completely, or errors similar to:

    ```text
    Failed to upgrade to WebSocket (REQUEST_METHOD: GET, HTTP_CONNECTION: close, HTTP_UPGRADE: )
    ```

    This indicates that the WebSocket upgrade request is not being forwarded correctly.

  </Accordion>

  <Accordion title="How do I fix it?">

    Ensure your Nginx configuration includes the required WebSocket headers:

    ```nginx
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    ```

    These headers allow Nginx to properly handle WebSocket upgrade requests over HTTPS.

  </Accordion>

</AccordionGroup>

## Budget totals do not update after deleting transactions

If a budget still shows spending from a transaction that was already deleted, the most likely cause is a stale aggregate cache. Sure hard-deletes the transaction entry, so the deleted transaction is gone; the stale value can come from cached budget or income totals that were not invalidated by deleting an older entry.

<AccordionGroup>

  <Accordion title="What causes this issue?">

    Budget totals are derived from entry aggregation queries. Older versions of Sure used the most recent entry update timestamp as part of the cache key, which meant deleting an older transaction could leave the cache key unchanged.

  </Accordion>

  <Accordion title="How was it fixed?">

    The entry aggregate cache version now includes both:

    - The current entry count
    - The newest entry `updated_at` timestamp

    Because the count changes when an entry is deleted, budget and income aggregate caches are invalidated even when the deleted transaction was not the most recently updated entry.

  </Accordion>

  <Accordion title="How can maintainers validate it?">

    Confirm the installed version includes the cache invalidation fix for `Family#entries_cache_version`, then re-check the affected budget after the next request or cache refresh. The regression test is:

    ```bash
    bin/rails test test/models/family_test.rb
    ```

  </Accordion>

</AccordionGroup>

## Chat fails immediately on a new account with a strict OpenAI-compatible provider

Some strict OpenAI-compatible providers (not OpenAI itself) reject chat requests with a schema validation error when a family has no tags, merchants, or categories yet. This happens because the assistant's tool schemas include `enum` fields built from family data — an empty family produces `enum: []`, which is invalid JSON Schema.

<AccordionGroup>

  <Accordion title="What causes this?">

    Sure builds tool schemas dynamically from your family's data (account names, categories, merchants, tags, tickers). When none of that data exists yet, the schema contains `enum: []`, which is invalid JSON Schema. OpenAI tolerates it, but strict OpenAI-compatible providers reject the entire request before the model runs.

  </Accordion>

  <Accordion title="How was it fixed?">

    Empty `enum` arrays are now pruned from tool schemas before they are sent. An empty enum falls back to a plain `string` type, which is valid. This fix applies to both chat tool definitions and the `/mcp` endpoint's `tools/list` response.

    If you are on an affected version, upgrade to the latest release. No configuration change is needed.

  </Accordion>

</AccordionGroup>

## Chat shows "assistant not available" before the model finishes

Self-hosted users running a local model (Ollama, LM Studio, etc.) may see the chat fail with "assistant not available" even though the model is still generating a reply and tokens are being billed.

<AccordionGroup>

  <Accordion title="What causes this?">

    Sure uses a whole-turn watchdog (`AI_RESPONSE_TIMEOUT`, default 90 seconds) that starts when the message is queued. Custom OpenAI-compatible providers use a synchronous code path — nothing renders until the full reply is generated — so the watchdog fires before the model finishes.

    Tool-using turns make this worse: each tool call adds another full model round. The total time the watchdog must cover is:

    ```text
    (1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) × OPENAI_REQUEST_TIMEOUT
      + tool execution time + queue wait
    ```

    With the defaults (`ASSISTANT_MAX_TOOL_CALL_ITERATIONS=5`, `OPENAI_REQUEST_TIMEOUT=60`), a worst-case turn can take up to 360 seconds of model time — far beyond the 90-second default.

  </Accordion>

  <Accordion title="How do I fix it?">

    The most effective approach is to lower `ASSISTANT_MAX_TOOL_CALL_ITERATIONS` (reducing the worst-case bound) and then size `AI_RESPONSE_TIMEOUT` using the formula above.

    For a local Ollama setup:

    ```bash
    OPENAI_REQUEST_TIMEOUT=300
    ASSISTANT_MAX_TOOL_CALL_ITERATIONS=2
    AI_RESPONSE_TIMEOUT=1200
    ```

    You can also set **Chat Response Timeout** from **Settings → Self-Hosting → AI Provider** in the UI. The environment variable takes precedence.

    See [Chat response timeout](/llm-support#chat-response-timeout) for full sizing guidance.

  </Accordion>

</AccordionGroup>

## Why is Sure not running auto-categorization and merchant detection on the same transactions again?

Sure caches AI-generated results to avoid redundant API calls and costs. Once a transaction has been processed by AI rules, it won't be re-processed unless you explicitly reset the AI cache.

<AccordionGroup>

  <Accordion title="Why does Sure cache AI results?">

    When AI rules process transactions, Sure stores:

    - **Enrichment records** - Which attributes were set by AI (category, merchant, etc.)
    - **Attribute locks** - Prevents rules from re-processing already-handled transactions

    This caching ensures:

    - Transactions aren't sent to the LLM repeatedly
    - API costs are minimized
    - Processing is faster on subsequent rule runs

  </Accordion>

  <Accordion title="How do I force Sure to re-process transactions?">

    To have AI rules re-process transactions, you need to reset the AI cache:

    1. Go to **Settings** → **Rules**
    2. Click the menu button (three dots)
    3. Select **Reset AI cache**
    4. Confirm the action

    After resetting, the next time rules run (either manually or during a sync), AI will re-process all transactions.

    **Note:** This will incur API costs if using a cloud provider.

  </Accordion>

  <Accordion title="When should I reset the AI cache?">

    Common scenarios for resetting the cache:

    - **Switching LLM models** - Different models may produce better categorizations
    - **After system updates** - New versions may have improved prompts
    - **Fixing miscategorizations** - When AI made systematic errors
    - **Testing** - During development or evaluation of AI features

    The AI cache is automatically cleared when you change the OpenAI model setting.

  </Accordion>

</AccordionGroup>

---

## File: guides/key-concepts/account-types.mdx

---
title: All Account Types Sure Offers
---

# All Account Types Sure Offers

Sure supports a comprehensive range of account types to track your complete financial picture. This page provides a complete reference of all available account types and their subtypes.

## Overview: Assets vs. Liabilities

| Classification | Types | Description |
| --- | --- | --- |
| **Assets** | Cash, Investment, Crypto, Property, Vehicle, Other Asset | Things you own |
| **Liabilities** | Credit Card, Loan, Other Liability | Things you owe |

## Asset Accounts

### Cash (Depository)

Liquid funds in bank accounts.

| Subtype | Short Name | Description |
| --- | --- | --- |
| checking | Checking | Everyday spending account |
| savings | Savings | Interest-bearing savings |
| hsa | HSA | Health Savings Account |
| cd | CD | Certificate of Deposit |
| money_market | MM | Money Market account |

### Investment

Investment accounts hold securities and have tax treatment based on account type.

#### United States

| Subtype | Short Name | Full Name | Tax Treatment |
| --- | --- | --- | --- |
| brokerage | Brokerage | Brokerage | Taxable |
| 401k | 401(k) | 401(k) | Tax-Deferred |
| roth_401k | Roth 401(k) | Roth 401(k) | Tax-Exempt |
| 403b | 403(b) | 403(b) | Tax-Deferred |
| 457b | 457(b) | 457(b) | Tax-Deferred |
| tsp | TSP | Thrift Savings Plan | Tax-Deferred |
| ira | IRA | Traditional IRA | Tax-Deferred |
| roth_ira | Roth IRA | Roth IRA | Tax-Exempt |
| sep_ira | SEP IRA | SEP IRA | Tax-Deferred |
| simple_ira | SIMPLE IRA | SIMPLE IRA | Tax-Deferred |
| 529_plan | 529 Plan | 529 Education Savings Plan | Tax-Advantaged |
| hsa | HSA | Health Savings Account | Tax-Advantaged |
| ugma | UGMA | UGMA Custodial Account | Taxable |
| utma | UTMA | UTMA Custodial Account | Taxable |

#### United Kingdom

| Subtype | Short Name | Full Name | Tax Treatment |
| --- | --- | --- | --- |
| isa | ISA | Individual Savings Account | Tax-Exempt |
| lisa | LISA | Lifetime ISA | Tax-Exempt |
| sipp | SIPP | Self-Invested Personal Pension | Tax-Deferred |
| workplace_pension_uk | Pension | Workplace Pension | Tax-Deferred |

#### Canada

| Subtype | Short Name | Full Name | Tax Treatment |
| --- | --- | --- | --- |
| rrsp | RRSP | Registered Retirement Savings Plan | Tax-Deferred |
| tfsa | TFSA | Tax-Free Savings Account | Tax-Exempt |
| resp | RESP | Registered Education Savings Plan | Tax-Advantaged |
| lira | LIRA | Locked-In Retirement Account | Tax-Deferred |
| rrif | RRIF | Registered Retirement Income Fund | Tax-Deferred |

#### Australia

| Subtype | Short Name | Full Name | Tax Treatment |
| --- | --- | --- | --- |
| super | Super | Superannuation | Tax-Deferred |
| smsf | SMSF | Self-Managed Super Fund | Tax-Deferred |

#### Europe

| Subtype | Short Name | Full Name | Tax Treatment |
| --- | --- | --- | --- |
| pea | PEA | Plan d'Epargne en Actions | Tax-Advantaged |
| pillar_3a | Pillar 3a | Private Pension (Pillar 3a) | Tax-Deferred |
| riester | Riester | Riester-Rente | Tax-Deferred |

#### Generic (Available Everywhere)

| Subtype | Short Name | Full Name | Tax Treatment |
| --- | --- | --- | --- |
| pension | Pension | Pension | Tax-Deferred |
| retirement | Retirement | Retirement Account | Tax-Deferred |
| mutual_fund | Mutual Fund | Mutual Fund | Taxable |
| angel | Angel | Angel Investment | Taxable |
| trust | Trust | Trust | Taxable |
| other | Other | Other Investment | Taxable |

### Crypto

Cryptocurrency holdings.

| Subtype | Short Name | Full Name | Manual Trades |
| --- | --- | --- | --- |
| wallet | Wallet | Crypto Wallet | No |
| exchange | Exchange | Crypto Exchange | Yes |

> [!NOTE]
> Only exchange accounts support manual trade entry. Wallet accounts are sync-only.

### Property

Real estate assets.

| Subtype | Short Name | Full Name |
| --- | --- | --- |
| single_family_home | Single Family Home | Single Family Home |
| multi_family_home | Multi-Family Home | Multi-Family Home |
| condominium | Condo | Condominium |
| townhouse | Townhouse | Townhouse |
| investment_property | Investment Property | Investment Property |
| second_home | Second Home | Second Home |

**Additional fields:**
- Address
- Area (square feet or square meters)
- Year built

### Vehicle

Cars, trucks, and other vehicles.

**Additional fields:**
- Make
- Model
- Year
- Mileage (miles or kilometers)

### Other Asset

Anything of value that doesn't fit other categories:
- Collectibles (art, antiques, coins)
- Jewelry and watches
- Equipment
- Other valuables

## Liability Accounts

### Credit Card

Track credit card balances and spending.

**Additional fields:**

| Field | Description |
| --- | --- |
| APR | Annual Percentage Rate |
| Available Credit | Remaining credit limit |
| Minimum Payment | Minimum amount due |
| Annual Fee | Yearly card fee |

### Loan

Track mortgages, auto loans, student loans, and other installment debt.

| Subtype | Short Name | Full Name |
| --- | --- | --- |
| mortgage | Mortgage | Mortgage |
| student | Student | Student Loan |
| auto | Auto | Auto Loan |
| other | Other | Other Loan |

**Additional fields:**

| Field | Description |
| --- | --- |
| Interest Rate | Annual interest rate |
| Rate Type | Fixed or variable |
| Term (months) | Loan duration |
| Monthly Payment | Calculated from loan details (fixed-rate only) |

> [!TIP]
> For fixed-rate loans, Sure automatically calculates the monthly payment based on your original balance, interest rate, and term.

### Other Liability

Debts that don't fit the above categories:
- Personal loans from friends/family
- Medical bills
- IOUs
- Tax obligations

## Tax Treatment Explained

Investment accounts have different tax implications:

| Treatment | Description | When Taxes Apply |
| --- | --- | --- |
| **Taxable** | Normal taxation | Gains taxed when realized |
| **Tax-Deferred** | Taxes postponed | Taxes paid on withdrawal |
| **Tax-Exempt** | No taxes on gains | Qualified withdrawals are tax-free |
| **Tax-Advantaged** | Special benefits | Varies by account type and use |

> [!IMPORTANT]
> Tax treatment in Sure is for informational and organizational purposes. Consult a tax professional for tax advice specific to your situation.

## Choosing the Right Account Type

| Your Situation | Recommended Account Type |
| --- | --- |
| Bank checking/savings | Cash (Depository) |
| Brokerage account | Investment |
| 401(k), IRA, pension | Investment (select appropriate subtype) |
| Bitcoin, Ethereum holdings | Crypto |
| Your home | Property |
| Your car | Vehicle |
| Credit card | Credit Card |
| Mortgage | Loan (mortgage) |
| Student loans | Loan (student) |
| Car loan | Loan (auto) |
| Money you owe someone | Other Liability |
| Collectibles, jewelry | Other Asset |

## Account Sharing and Permissions

Sure supports sharing accounts with other family members, allowing collaborative financial management while maintaining appropriate access controls.

### How Account Sharing Works

Accounts can be shared with other members of your family with different permission levels:

**Permission Levels:**

| Permission | Can View | Can Edit | Can Delete |
| --- | --- | --- | --- |
| **View Only** | ✅ Yes | ❌ No | ❌ No |
| **Edit** | ✅ Yes | ✅ Yes | ❌ No |
| **Full Access** | ✅ Yes | ✅ Yes | ✅ Yes |

### Sharing an Account

To share an account with family members:

1. Navigate to the account you want to share
2. Open the account settings or details page
3. Find the "Sharing" or "Permissions" section
4. Click **Share Account** or **Manage Access**
5. Select the family member you want to share with
6. Choose their permission level
7. Click **Save** or **Share**

### Managing Shared Accounts

**As an account owner:**
- You maintain full control over accounts you create
- You can modify or revoke sharing permissions at any time
- You can see who has access to each account

**As a shared user:**
- Shared accounts appear in your account list
- Your available actions depend on your permission level
- You can see who owns the account

### Account Scoping

Account sharing affects how data appears throughout Sure:

**Dashboard and reports:**
- Your dashboard shows all accounts you own plus accounts shared with you
- Net worth calculations include all accessible accounts
- Reports and budgets can be filtered by account ownership

**Transactions and activity:**
- You can view transactions for any account you have access to
- Edit permissions allow you to categorize, tag, and modify transactions
- Full access allows you to delete transactions and manage account settings

**Provider connections:**
- Only account owners and users with full access can manage provider connections
- Synced data is visible to all users with access to the account

### Family-Level Features

Some features operate at the family level and are accessible to all family members:

- **Recurring transactions**: Scoped to accounts you have access to
- **Merchants**: Shared across the family for consistent categorization
- **Categories and tags**: Available to all family members
- **Rules**: Can be created by any family member and apply to their accessible accounts

> [!IMPORTANT]
> Account sharing is designed for trusted family members. Users with edit or full access permissions can modify financial data, so only share accounts with people you trust.

### Privacy and Security

**What's shared:**
- Account balances and transaction history
- Holdings and investment details (for investment accounts)
- Account metadata (name, type, institution)

**What's not shared:**
- Your login credentials
- Provider API keys (unless explicitly configured)
- Personal settings and preferences

**Admin controls:**
- Family admins can manage provider connections for the entire family
- Only admins can configure third-party provider integrations
- Account owners always retain control over their own accounts

## Related Pages

- [How Asset Accounts Work](/guides/key-concepts/account-types#asset-accounts)
- [How Debt Accounts Work](./debt-accounts.mdx)
- [Managing Investment Accounts](./investment-accounts.mdx)
- [Transfers Between Accounts](./transfers.mdx)

---

## File: guides/key-concepts/debt-accounts.mdx

---
title: How Debt Accounts Work
---

# How Debt Accounts Work

Debt accounts (also called liability accounts) track money you owe to others. This includes credit cards, loans, and any other obligations.

## Why Debt Math Seems "Backwards"

If you're used to thinking about asset accounts, debt accounts can feel counterintuitive at first. Here's the key difference:

| Balance Type | What It Means |
| --- | --- |
| **Positive balance** | You owe money |
| **Negative balance** | The lender owes you money (overpayment, refund) |

And transactions work in the opposite direction from assets:

| Action | Asset Account | Debt Account |
| --- | --- | --- |
| Spend money | Balance decreases | Balance increases (more debt) |
| Receive money | Balance increases | Balance decreases (less debt) |

> [!TIP]
> Think of a debt balance as "Amount Owed" rather than "money I have." A higher number means you owe more, not that you have more.

## Credit Card Example

Let's walk through a typical credit card scenario:

| Action | Balance Change | New Balance | What It Means |
| --- | --- | --- | --- |
| Starting balance | — | $500 | You owe $500 |
| Buy groceries (+$80) | +$80 | $580 | You now owe $580 |
| Gas purchase (+$45) | +$45 | $625 | You now owe $625 |
| Make payment (-$300) | -$300 | $325 | You now owe $325 |

Notice how spending **increases** the balance (you owe more) and payments **decrease** it (you owe less).

### Overpayment Scenario

What happens if you pay more than you owe?

| Action | Balance Change | New Balance | What It Means |
| --- | --- | --- | --- |
| Starting balance | — | $100 | You owe $100 |
| Make payment (-$150) | -$150 | -$50 | Card company owes you $50 |
| Buy coffee (+$5) | +$5 | -$45 | Card company owes you $45 |

A negative balance on a credit card means you've overpaid—the credit card company owes *you* money. This might happen from:

- Overpayment
- Refunds for returned purchases
- Statement credits or rewards

## Types of Debt Accounts

Sure supports three types of liability accounts:

### Credit Card

Track your credit card balances and spending. Credit card accounts can include:

| Field | Description |
| --- | --- |
| APR | Annual percentage rate (interest rate) |
| Available Credit | Remaining credit you can use |
| Minimum Payment | Minimum amount due |
| Annual Fee | Yearly card fee |

### Loan

Track mortgages, auto loans, student loans, and other installment debt.

| Subtype | Description |
| --- | --- |
| Mortgage | Home loan |
| Student | Student loan debt |
| Auto | Car loan |
| Other | Personal loans, etc. |

Loan accounts support additional fields:

| Field | Description |
| --- | --- |
| Interest Rate | Annual interest rate |
| Rate Type | Fixed or variable rate |
| Term | Loan duration (months) |
| Monthly Payment | Calculated based on loan details |

> [!NOTE]
> For fixed-rate loans, Sure can calculate your monthly payment automatically based on the original balance, interest rate, and term.

### Other Liability

For debts that don't fit the above categories:

- Personal loans from friends/family
- Medical bills
- IOUs
- Tax obligations

## How Debt Affects Net Worth

Liabilities **reduce** your net worth:

```
Net Worth = Total Assets - Total Liabilities
```

When you pay down debt, your net worth increases (even though your cash decreases). This is because the debt reduction outweighs the cash spent—you're converting a liquid asset (cash) into reduced liability (less debt).

### Example: Paying Off a Credit Card

| Account | Before Payment | After $500 Payment |
| --- | --- | --- |
| Checking (Asset) | $2,000 | $1,500 |
| Credit Card (Liability) | $500 | $0 |
| **Net Worth** | **$1,500** | **$1,500** |

Your net worth stays the same because you traded $500 of cash for $500 of debt reduction. But now you have no credit card debt!

## Adding Debt Accounts

To add a debt account:

1. Click **+ Add Account** on your dashboard
2. Select the liability type (Credit Card, Loan, or Other Liability)
3. Enter your account details:
   - Account name
   - Current balance (the amount you owe)
   - For loans: interest rate, term, and rate type
4. Click **Create Account**

> [!IMPORTANT]
> Make sure to select a liability account type (Credit Card, Loan, or Other Liability) when adding debt. If you accidentally create a Cash account for your credit card, the balance calculations will be incorrect.

## Understanding Loan Payments

When you make a loan payment, the payment typically covers:

1. **Interest** - Cost of borrowing
2. **Principal** - Reducing the amount you owe

Sure tracks your loan balance, which represents the remaining principal. As you make payments, the balance decreases until the loan is paid off.

> [!TIP]
> To track loan payments accurately, create a [transfer](./transfers.mdx) from your checking account to your loan account. This properly reflects the payment in both accounts.

---

## File: guides/key-concepts/investment-accounts.mdx

---
title: Managing Investment Accounts
---

# Managing Investment Accounts

Investment accounts in Sure go beyond simple balance tracking. They help you understand your portfolio through holdings, trades, cost basis tracking, and investment activity labels.

## Understanding Holdings

A **holding** represents your position in a specific security. For example, if you own 50 shares of Apple stock, that's one holding.

Each holding tracks:

- **Security** - What you own (stock, ETF, mutual fund, crypto)
- **Quantity** - How many shares or units you hold
- **Price** - Current market price per share
- **Amount** - Total current value (quantity x price)
- **Cost Basis** - What you originally paid

## Recording Trades

Investment accounts support manual trade entry to track your buy and sell activity.

### Trade Types

When recording a trade, you can specify:

- **Buy**: Purchase of securities
- **Sell**: Sale of securities
- **Dividend**: Dividend income (recorded as a trade with qty: 0 and price: 0)
- **Interest**: Interest income (recorded as a trade with qty: 0 and price: 0)

### Recording Dividends and Interest

Dividends and interest are recorded as trades to maintain data integrity:

**Dividends:**
- Require a security selection (the security that paid the dividend)
- Recorded with qty: 0 and price: 0
- Amount represents the dividend payment received

**Interest:**
- Security selection is optional
- If no security is selected, Sure creates a synthetic cash security for the account
- Recorded with qty: 0 and price: 0
- Amount represents the interest earned

This approach ensures all investment income is properly tracked while maintaining the requirement that trades reference a security.

### Transaction Fees

Trades support an optional **fee** field to track transaction costs:

- Fees are included in the total amount calculation: `qty × price + fee`
- Fees default to 0 if not specified
- Useful for tracking brokerage commissions, exchange fees, or other trading costs

For example:
- Buy 10 shares at $100/share with a $5 fee = $1,005 total
- Sell 10 shares at $110/share with a $5 fee = $1,095 total (proceeds after fee)

To add a fee to a trade:
1. Open the trade form (create or edit)
2. Enter the fee amount in the fee field
3. The total amount updates automatically to include the fee

Fees are automatically factored into your cost basis calculations and realized gains/losses.

## Securities Types

Sure supports various security types:

| Type | Examples |
| --- | --- |
| Stocks | Individual company shares (AAPL, GOOGL) |
| ETFs | Exchange-traded funds (SPY, VTI) |
| Mutual Funds | Pooled investment funds |
| Crypto | Bitcoin, Ethereum, other cryptocurrencies |
| Bonds | Fixed-income securities |

## Cost Basis Tracking

Cost basis is the original purchase price of your investments. It's essential for:

- Calculating gains and losses
- Understanding investment performance
- Tax reporting

### Cost Basis Sources

Sure tracks cost basis from three sources, in order of priority:

| Priority | Source | Description |
| --- | --- | --- |
| 1 (Highest) | **Manual** | Values you enter directly |
| 2 | **Calculated** | Computed from your buy trades |
| 3 (Lowest) | **Provider** | Imported from your financial institution |

This hierarchy ensures your manual entries always take precedence, calculated values override provider data, and provider data is used when nothing else is available.

### Setting Cost Basis Manually

You can set cost basis in two ways:

**From the Holdings List:**

1. Navigate to your investment account
2. Find the holding in your portfolio
3. Click the pencil icon next to the average cost
4. Enter either:
   - **Total cost basis**: The total amount you paid for all shares
   - **Per-share cost**: The average price per share
5. Click **Save**

**From the Holding Drawer:**

1. Click on a holding to open its detail drawer
2. In the Overview section, click the pencil icon next to "Average Cost"
3. Enter the cost basis (total or per-share)
4. Click **Save**

> [!TIP]
> The cost basis editor provides real-time conversion between total and per-share values. Enter whichever format you have available, and Sure calculates the other automatically.

### Lock Protection

When you manually set a cost basis, Sure automatically **locks** it to prevent automatic updates from overwriting your value. You'll see a lock icon indicating the value is protected.

This ensures your manual entries remain intact during account syncs.

### Unlocking Cost Basis

If you want Sure to recalculate cost basis from your trades:

1. Open the holding drawer
2. Scroll to the **Settings** section
3. Find "Cost basis locked"
4. Click **Unlock**

After unlocking:
- The lock icon disappears
- Future syncs can update the cost basis
- Calculated values (from trades) will replace the manual value

## Recording Trades

Investment accounts support recording various types of trades to track your portfolio activity.

### Trade Types

| Type | Description | Security Required |
| --- | --- | --- |
| **Buy** | Purchase of securities | Yes |
| **Sell** | Sale of securities | Yes |
| **Dividend** | Dividend payments received | Yes |
| **Interest** | Interest earned on cash or securities | No (optional) |

### Recording Dividends and Interest

Dividends and interest are recorded as trades with zero quantity and zero price, allowing them to be associated with specific securities while maintaining data integrity.

**Dividends:**
- Require a security selection (the stock/fund paying the dividend)
- Recorded as a trade with qty: 0 and price: 0
- Amount represents the dividend payment received

**Interest:**
- Security selection is optional
- When no security is selected, Sure creates a synthetic cash security for the account
- Recorded as a trade with qty: 0 and price: 0
- Amount represents the interest earned

To record a dividend or interest payment:

1. Navigate to your investment account
2. Click **+ New Trade**
3. Select **Dividend** or **Interest** from the type dropdown
4. For dividends: Select the security that paid the dividend
5. For interest: Optionally select a security, or leave blank for cash interest
6. Enter the amount received
7. Set the date
8. Click **Save**

> [!NOTE]
> Dividends require a security selection, while interest can be recorded without one. When no security is selected for interest, Sure automatically creates a per-account synthetic cash security (kind: `cash`, offline: `true`) to maintain data integrity.

### Trade Fees

All trades support an optional fee field to track transaction costs:

- Fees are included in the total amount calculation: `total = (qty × price) + fee`
- Fees default to 0 if not specified
- Fees are tracked separately for accurate cost basis calculations

## Investment Activity Labels

Activity labels help you classify investment transactions. They appear as badges in your transaction list and help you understand your investment activity at a glance.

## Recording Dividends and Interest

Dividends and interest are recorded as **trades** with zero quantity and zero price. This approach maintains data integrity while properly tracking investment income:

- **Dividends** require a security to be selected (the stock or fund paying the dividend)
- **Interest** can optionally specify a security, or will use a synthetic cash security for the account
- Both appear in your holdings and trade history
- Both are properly labeled with their respective activity types
- The zero-quantity, zero-price approach preserves the trade model's required security association

When creating a dividend or interest transaction:

1. Select **Dividend** or **Interest** from the trade type dropdown
2. For dividends: Choose the security paying the dividend (required)
3. For interest: Optionally select a security, or leave blank to use the account's cash position
4. Enter the amount received
5. The trade is created with qty: 0 and price: 0, maintaining the security association

## Trade Fees

All trades support an optional **fee** field to track transaction costs like brokerage commissions, exchange fees, or other trading expenses.

### How Fees Work

- Fees are included in the total transaction amount: `Total = (Quantity × Price) + Fee`
- Fees default to 0 if not specified
- Fees can be added when creating or editing trades
- The fee field auto-submits like other trade fields

### Adding Fees to Trades

When creating or editing a trade:

1. Enter the quantity and price as usual
2. Add the fee amount in the **Fee** field
3. The total amount updates automatically to include the fee
4. Save the trade

Example: Buying 10 shares at $50 with a $5 commission:
- Quantity: 10
- Price: $50
- Fee: $5
- **Total: $505** (10 × $50 + $5)

Fees are particularly important for:
- Accurate cost basis calculations
- Understanding true investment returns
- Tax reporting and record keeping

### Available Activity Labels

| Label | Description |
| --- | --- |
| **Buy** | Purchase of securities |
| **Sell** | Sale of securities |
| **Contribution** | Money added to the investment account |
| **Withdrawal** | Money removed from the investment account |
| **Dividend** | Dividend payments received (recorded as trades with qty: 0) |
| **Interest** | Interest earned (recorded as trades with qty: 0) |
| **Reinvestment** | Dividends or distributions reinvested |
| **Sweep In** | Cash swept into the account |
| **Sweep Out** | Cash swept out of the account |
| **Fee** | Account or transaction fees |
| **Exchange** | Currency or security exchanges |
| **Transfer** | Internal transfers |
| **Other** | Miscellaneous transactions |

> [!NOTE]
> Dividends and interest are recorded as trades with zero quantity and zero price. Dividends require a security selection, while interest can optionally use a synthetic cash security if no specific security is selected.

### Setting Labels Manually

1. Open a transaction from an investment or crypto account
2. Scroll to the **Settings** section
3. Find "Activity type"
4. Select a label from the dropdown
5. The change saves automatically

### Setting Labels with Rules

Create rules to automatically label transactions based on patterns:

1. Go to **Settings > Rules**
2. Create a new rule
3. Set conditions (e.g., "IF transaction name contains 'DIVIDEND'")
4. Add action: "Set investment activity label"
5. Choose the label (e.g., "Dividend")
6. Save the rule

Example rules:
- IF name contains "DIVIDEND" THEN set label to "Dividend"
- IF name contains "INTEREST" THEN set label to "Interest"
- IF name contains "FEE" THEN set label to "Fee"

> [!NOTE]
> Internal movement labels (Transfer, Sweep In, Sweep Out, Exchange) are automatically excluded from budget calculations since they represent cash management rather than income or expenses.

## Tax Treatment Categories

Investment accounts have different tax implications based on their type:

| Category | Description | Examples |
| --- | --- | --- |
| **Taxable** | Gains taxed when realized | Brokerage, UGMA/UTMA |
| **Tax-Deferred** | Taxes deferred until withdrawal | 401(k), Traditional IRA, 403(b) |
| **Tax-Exempt** | Qualified gains are tax-free | Roth IRA, Roth 401(k), ISA |
| **Tax-Advantaged** | Special tax benefits with conditions | 529 Plan, HSA |

Sure automatically assigns tax treatment based on account subtype, but you can adjust it manually if needed.

## Understanding Gains and Losses

Sure helps you track two types of gains:

### Unrealized Gains/Losses

Paper gains or losses on holdings you still own:

```
Unrealized Gain = Current Value - Cost Basis
```

If you bought 10 shares at $100 ($1,000 total) and they're now worth $1,200, you have an unrealized gain of $200.

### Realized Gains/Losses

Actual gains or losses from securities you've sold. These are what matter for taxes.

The **Trend** indicator on each holding shows your unrealized gain or loss, calculated from your cost basis. If cost basis is unknown (no trades and no manual entry), the trend won't display.

## Crypto-Specific Features

Crypto accounts work similarly to investment accounts but have unique characteristics:

| Subtype | Description | Manual Trades |
| --- | --- | --- |
| Wallet | Self-custody or provider-synced | No |
| Exchange | Centralized exchanges (Binance, Coinbase, etc.) | Yes |

- **Exchange accounts** support manual trade entry, letting you record buys and sells
- **Wallet accounts** are sync-only, holdings are updated through provider connections

Crypto is taxable by default, though it can be held in tax-advantaged accounts (for example, a self-directed IRA).

## Adding Investment Accounts

1. Click **+ Add Account** on your dashboard
2. Select **Investment** or **Crypto**
3. Choose the appropriate subtype (see [Account Types](./account-types.mdx) for all options)
4. Enter your account details:
   - Account name
   - Current balance
   - Tax treatment (if different from default)
5. Click **Create Account**

After creating the account, you can add holdings manually or connect a provider to sync them automatically.

---

## File: guides/key-concepts/transfers.mdx

---
title: Transfers Between Accounts
---

# Transfers Between Accounts

A transfer moves money between your own accounts. Unlike income or expenses, transfers don't change your net worth—they simply move funds from one place to another.

## How Transfers Work

Every transfer consists of two linked transactions:

1. **Outflow** - Money leaving one account (negative amount)
2. **Inflow** - Money entering another account (positive amount)

These two transactions are linked together as a single transfer.

### Example: Moving Money to Savings

When you transfer $500 from checking to savings:

| Account | Transaction | Amount |
| --- | --- | --- |
| Checking | Outflow | -$500 |
| Savings | Inflow | +$500 |
| **Net Effect** | | **$0** |

Your total net worth stays the same—the money simply moved from one account to another.

## Transfer Types

Sure recognizes different types of transfers based on the destination account:

| Transfer Type | When It's Used | Budget Impact |
| --- | --- | --- |
| **Funds Movement** | Between asset accounts | Excluded |
| **CC Payment** | Payment to a credit card | Excluded |
| **Loan Payment** | Payment to a loan account | Treated as expense |
| **Investment Contribution** | To investment or crypto accounts | Excluded |

### Funds Movement

General transfers between asset accounts:
- Checking to savings
- Savings to money market
- Between investment accounts

These are excluded from budgets because they don't represent spending or income.

### Credit card payment

When you pay your credit card bill, Sure creates a `cc_payment` transfer:
- Outflow from your checking account
- Inflow to your credit card (reducing your debt)

This is excluded from budgets because the spending already happened when you used the credit card.

When syncing transactions from a provider, Sure automatically classifies transactions to credit card accounts as credit card payment transfers.

### Loan payment

Payments to loan accounts (mortgage, auto, student loans) are treated as `loan_payment` transfers.

> [!NOTE]
> Loan payments can be treated as expenses in budgets since they represent an ongoing financial obligation, even though they also reduce your debt.

When syncing transactions from a provider, Sure automatically classifies transactions to loan accounts as loan payment transfers.

### Investment Contribution

Transfers to investment or crypto accounts:
- Contributing to your 401(k)
- Adding funds to a brokerage account
- Moving money to a crypto exchange

These are excluded from budgets because you're saving/investing, not spending.

## Creating Transfers Manually

To create a transfer:

1. Go to the **Transactions** page
2. Click **+ New Transaction**
3. Select **Transfer**
4. Choose:
   - **From account** - Where money is coming from
   - **To account** - Where money is going
   - **Amount** - How much to transfer
   - **Date** - When the transfer occurred
5. Click **Add transaction**

Sure automatically creates both the outflow and inflow transactions and links them together.

## Automatic Transfer Matching

When you sync accounts with a provider (Plaid, SimpleFIN, etc.), Sure attempts to automatically match transfers between accounts.

### How Matching Works

Sure looks for transaction pairs that:
- Are from different accounts in the same family
- Have opposite amounts (one positive, one negative)
- Occurred within a date window

### Date Windows

| Transaction Status | Window |
| --- | --- |
| Pending transactions | 4 days |
| Confirmed transactions | 30 days |

### Suggested Matches

When Sure finds a potential transfer match, it creates a **pending** transfer suggestion. You can:

1. **Confirm** - Accept the match and link the transactions
2. **Reject** - Dismiss the match (transactions remain separate)

To review suggested transfers:
1. Open a transaction that may be part of a transfer
2. Look for transfer match suggestions
3. Confirm or reject as appropriate

## Cross-Currency Transfers

When transferring between accounts with different currencies:

- Sure validates that amounts have opposite signs
- The exact amounts don't need to match (due to exchange rates)
- Both transactions are linked as a transfer

Example: Transferring from a USD checking account to a EUR savings account:

| Account | Currency | Amount |
| --- | --- | --- |
| US Checking | USD | -$1,000 |
| Euro Savings | EUR | +€920 |

The transfer is valid because one amount is negative and one is positive, even though the numbers differ due to the exchange rate.

## Transfer Summary Totals

When viewing your transaction list filtered by transfers, Sure displays **Inflow** and **Outflow** totals instead of the standard Income and Expenses labels.

### How It Works

**Transfer-only filter:**
- When filtering exclusively by "Transfer" type, the summary bar shows:
  - **Inflow**: Total positive transfer amounts (money coming in)
  - **Outflow**: Total negative transfer amounts (money going out)

**Mixed filters:**
- When filtering by transfers along with other transaction types (income, expenses), the summary bar shows standard Income/Expenses labels
- Transfer amounts are excluded from these totals since they don't represent actual income or spending

This helps you understand your cash movement patterns when reviewing transfers in isolation.

> [!NOTE]
> When filtering by both transfers and other transaction types, transfer amounts are excluded from income and expense totals to avoid confusion.

## How Transfers Affect Budgets

Transfers are generally **excluded** from budget calculations because they don't represent actual spending or income:

| Transfer Type | Budget Treatment |
| --- | --- |
| Funds Movement | Excluded |
| CC Payment | Excluded |
| Investment Contribution | Excluded |
| Loan Payment | Can be included as expense |

> [!TIP]
> If you want to budget for loan payments, you may want to track them as expenses. Sure treats loan payments specially since they represent ongoing obligations.

## Understanding Net Worth Impact

Transfers are **zero-sum** for net worth:

- Moving $500 from checking to savings: Net worth unchanged
- Paying $500 to credit card: Net worth unchanged (reduced cash = reduced debt)
- Contributing $500 to 401(k): Net worth unchanged (reduced cash = increased investments)

The only time a transfer affects net worth is if there are fees involved (which would be a separate expense transaction).

## Common Transfer Scenarios

### Paying Off a Credit Card

1. Money leaves your checking account (-$500)
2. Credit card balance decreases (-$500 debt)
3. Net worth: unchanged

### Making a Mortgage Payment

1. Money leaves your checking account (-$2,000)
2. Loan balance decreases (-$1,700 principal, $300 goes to interest)
3. Note: The interest portion is technically an expense

> [!IMPORTANT]
> Mortgage payments include both principal (reducing debt) and interest (an expense). Sure tracks the full payment as a loan transfer, but the interest portion represents a true cost.

### Contributing to Retirement

1. Money leaves your checking account (-$500)
2. Investment account balance increases (+$500)
3. Net worth: unchanged (cash converted to investments)

### Transferring Between Banks

1. Money leaves Bank A checking (-$1,000)
2. Money arrives at Bank B checking (+$1,000)
3. Net worth: unchanged

## Troubleshooting Transfers

### Duplicate Transactions

If you see both a transfer and separate expense/income transactions:
- Check if the transactions should be linked as a transfer
- Confirm or reject the transfer suggestion
- Delete duplicate transactions if needed

### Unmatched Transfers

If Sure didn't automatically match a transfer:
- The amounts may not match exactly (due to fees or timing)
- The dates may be too far apart
- You can manually create a transfer to link them

### Wrong Transfer Type

If a transfer is categorized incorrectly:
- Check that both accounts are the correct type
- The transfer type is determined by the destination account

---

## File: guides/key-concepts/untitled-page.mdx

---
title: How Asset Accounts Work
---

# How Asset Accounts Work

Assets are things you own that have monetary value. In Sure, asset accounts track your wealth—from the cash in your checking account to the value of your home.

## Understanding Asset Balances

Asset account balances work intuitively:

- **Income increases your balance** (money coming in)
- **Expenses decrease your balance** (money going out)

### Example: Checking Account

Let's walk through a simple example:

| Action | Balance Change | New Balance |
| --- | --- | --- |
| Starting balance | — | $1,000 |
| Paycheck deposit (+$2,500) | +$2,500 | $3,500 |
| Rent payment (-$1,200) | -$1,200 | $2,300 |
| Grocery shopping (-$150) | -$150 | $2,150 |

This matches what you see in your bank account—straightforward and predictable.

## Types of Asset Accounts

Sure supports six types of asset accounts:

### Cash (Depository)

Liquid funds you can access immediately. Includes:

| Subtype | Description |
| --- | --- |
| Checking | Everyday spending account |
| Savings | Interest-bearing savings |
| HSA | Health Savings Account |
| CD | Certificate of Deposit |
| Money Market | Money market account |

### Investment

Accounts that hold securities like stocks, bonds, and mutual funds. Investment accounts have additional features for tracking holdings, trades, and cost basis. See [Managing Investment Accounts](./investment-accounts.mdx) for details.

### Crypto

Cryptocurrency holdings, either in:

| Subtype | Description |
| --- | --- |
| Wallet | Self-custody or provider-synced wallets |
| Exchange | Centralized exchanges with trade history |

> [!NOTE]
> Only exchange accounts support manual trade entry. Wallet accounts are sync-only.

### Property

Real estate you own:

| Subtype | Description |
| --- | --- |
| Single Family Home | Primary residence |
| Multi-Family Home | Duplex, triplex, etc. |
| Condominium | Condo unit |
| Townhouse | Townhouse property |
| Investment Property | Rental or investment real estate |
| Second Home | Vacation home |

Property accounts can track additional details like address, area (square footage), and year built.

### Vehicle

Cars, trucks, motorcycles, and other vehicles you own. Vehicle accounts can track make, model, year, and mileage.

### Other Asset

Anything else of value that doesn't fit the categories above:

- Collectibles (art, antiques, coins)
- Jewelry and watches
- Equipment
- Other valuables

## Liquid vs. Illiquid Assets

Sure distinguishes between liquid and illiquid assets:

**Liquid assets** can be quickly converted to cash without significant loss of value:
- Cash accounts (checking, savings)
- Most investments (stocks, bonds, ETFs)

**Illiquid assets** take time to sell or may lose value if sold quickly:
- Property
- Vehicles
- Some investments (private equity, real estate funds)
- Other assets (collectibles, jewelry)

This distinction helps you understand not just your total net worth, but how much of it you could access quickly if needed.

## How Assets Affect Net Worth

Your net worth is calculated as:

```
Net Worth = Total Assets - Total Liabilities
```

Every asset account contributes positively to your net worth. When asset values increase (your investments grow, your home appreciates), your net worth increases. When you spend from asset accounts, that balance decreases—but the money likely went toward something (an expense, a purchase, or a debt payment).

> [!TIP]
> For a complete picture of your finances, make sure to add both your assets and your [debt accounts](./debt-accounts.mdx). Sure calculates your true net worth by subtracting what you owe from what you own.

## Adding Asset Accounts

To add an asset account:

1. Click **+ Add Account** on your dashboard
2. Select the asset type (Cash, Investment, Crypto, Property, Vehicle, or Other Asset)
3. Enter your account details:
   - Account name
   - Current balance
   - Subtype (if applicable)
4. Click **Create Account**

Your new asset account will appear in your accounts list and contribute to your net worth calculation immediately.

---

## File: guides/app-features/assistant-sidebar.mdx

---
title: "AI assistant sidebar"
---

Sure includes an AI assistant sidebar on desktop. If you do not want to use it yet, you can hide it and bring it back later.

## Hide or show the AI sidebar on desktop

On desktop, use the **panel-right** button in the top bar to toggle the right-hand AI sidebar.

- Click it once to collapse the AI sidebar
- Click it again to reopen the AI sidebar
- Sure saves this preference to your account

This is useful if you want more room for the main app content or simply do not want to use the assistant yet.

## When the sidebar is hidden by default

Sure can also hide the AI sidebar by default for some people:

- New `member` and `guest` users start with the AI sidebar hidden when AI is not available
- `admin` users still see it by default so they can finish AI setup or troubleshooting

If AI becomes available later, you can reopen the sidebar at any time with the same desktop toggle.

## Related settings

- For AI provider setup, see [LLM support](/llm-support)
- For general app settings, see [Settings](/guides/app-features/settings)

---

## File: guides/app-features/budgets.mdx

---
title: "Budgets"
---

# Budgets

Budgets help you set spending limits for different categories and track how well you're sticking to them throughout the month.

## How Budgets Work

When you create a budget, you assign a spending limit to a category. As you spend money in that category, Sure tracks your progress and shows you:

- **Amount spent** so far this month
- **Amount remaining** in your budget
- **Status indicators** showing if you're on track, near your limit, or over budget

### Planning ahead

You can create budgets for future months, up to 2 years in advance. This lets you plan your spending ahead of time and set category limits before the month begins. Navigate forward using the month selector to set up upcoming budgets.



## Setting Up Category Budgets

Each category can have its own budget. For example, you might set:

- Food & Dining: \$500/month
- Entertainment: \$200/month
- Transportation: \$300/month

Sure shows helpful daily suggestions like "\$29.91 suggested per day for 11 remaining days" to help you pace your spending.

When editing a subcategory's budget, you'll see "Shared" as the placeholder text. Leave the field empty or set it to \$0 to share the parent category's budget. Hover over the field to see the tooltip: "Leave empty to share parent's budget".

## Subcategories and Shared Budgets

You can organize your spending further with subcategories. For example, "Food & Dining" might include:

- Coffee & Takeout
- Groceries
- Restaurants



### Individual budgets

Give each subcategory its own limit. This works well when you want strict control over specific types of spending.

### Shared budgets

Leave a subcategory's budget empty to share the parent category's budget. This is great when you want flexibility across related expenses.

**Example:** Set "Food & Dining" to \$500, but leave the subcategories without individual limits. Now all your food spending draws from the same \$500 pool, whether you're buying groceries or eating out.

When a subcategory shares its parent's budget, you'll see a "(shared)" label next to the budget amount, making it clear which subcategories are drawing from the shared pool.

### Mixed approach

Combine both methods. Set a fixed budget for some subcategories while others share the remaining amount.

**Example:**

- Food & Dining: \$500 total
- Restaurants: \$100 (fixed limit)
- Groceries and Coffee share the remaining \$400

When you set an individual budget for a subcategory, that amount is "ring-fenced" from the parent budget. Only that subcategory can use it, and it won't be available to other subcategories sharing the parent's budget.

**How it works:**
- **Restaurants** has \$100 remaining (\$100 budget - \$0 spent)
- **Groceries** and **Coffee** share \$350 remaining (\$400 shared pool - \$50 spent between them)
- The parent **Food & Dining** shows the total remaining across all subcategories

### Parent auto-aggregation

When you edit a subcategory's budget, the parent category's budget automatically updates to reflect the change. You don't need to manually adjust the parent — Sure keeps it in sync for you.

**How it works:**

- When you increase a subcategory's budget, the parent's budget increases by the same amount.
- When you decrease a subcategory's budget, the parent's budget decreases accordingly.
- If the parent had extra budget beyond the sum of its subcategories (a "reserve"), that reserve is preserved during the update.
- The parent's budget never drops below the combined total of its subcategories.

**Example:**

- Food & Dining starts at \$500 with Restaurants at \$100 and Groceries at \$150 (\$250 reserve in the shared pool)
- You increase Groceries from \$150 to \$200
- Food & Dining automatically updates to \$550, preserving the \$250 reserve

## Status Indicators

Sure uses color-coded badges to show your budget status at a glance:

- **On Track** (green) - You're within budget and pacing well
- **Near Limit** (yellow) - Getting close to your limit
- **Over Budget** (red) - You've exceeded your budget

## Tax-advantaged accounts and budgets

Sure automatically excludes activity from tax-advantaged investment accounts when calculating budget spending. This ensures your budgets accurately reflect your day-to-day spending without being skewed by investment contributions or withdrawals.

**Excluded account types:**
- 401(k) accounts
- IRA accounts (Traditional and Roth)
- 403(b) accounts
- Other tax-advantaged retirement accounts

Transactions from these accounts won't count toward your budget limits, keeping your budget focused on regular income and expenses.

## Tips for Effective Budgeting

1. **Start simple** - Begin with just a few major categories before adding detail
2. **Review monthly** - Check your budgets at the end of each month and adjust as needed
3. **Use shared budgets** for categories where you want flexibility between subcategories
4. **Use individual limits** when you need strict control over specific spending