sure Documentation

The personal finance app for everyone (by everyone)

9,330 stars Ruby Ruby 1 file Β· ~58,346 tokens
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy sure on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name sure -p 3000:3000 --restart always we-promise/sure:latest

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

File: README.md




Sure: The personal finance app for everyone

Get
involved: Discord β€’ Website β€’ Issues

[!IMPORTANT]
This repository is a community fork of the now-abandoned Maybe Finance project.
Learn more in their final release doc.

Backstory

The Maybe Finance (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.

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

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.

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:

For further instructions, see guides below.

Setup Guides

One-click Install

Managed OpenClaw for Sure Finances

License and Trademarks

Maybe and Sure are both distributed under
an AGPLv3 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:[email protected]:5432/sure
    REDIS_URL: redis://:[email protected]: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 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 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.

text
/* 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: $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
  • Open an issue
  • Or if you know the answer, open a PR!

1. Creating your Sure Account

Once Sure is installed, open a browser and navigate to localhost:3000.
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:

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:

text
/* 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
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
  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

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

Google Gemini (via OpenRouter)

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

Local LLM Setup (Ollama)

Ollama 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.

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 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
[email protected]  # 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 (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
[email protected]    # 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, 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 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
for which side owns what, and the blueprint
it implements.

Security with Pipelock

When 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.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 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: [email protected]        # 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 and Pipelock 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, 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 or self-host Langfuse

  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.

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 (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

Support

For issues with AI features:

  1. Check this documentation first
  2. Search existing GitHub 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
  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 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:

text
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.

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
    [email protected]   # 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 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.

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

  • 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.

text
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 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

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

Development

Install the Mintlify CLI to preview your documentation changes locally. To install, use the following command:

text
npm i mint

Run the following command at the root of your documentation, where your docs.json is located:

text
mint dev

View your local preview at http://localhost:3000.

Publishing changes

Install our GitHub app from your dashboard 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


File: guides/knowledge-base/faq.mdx


title: "FAQ"

Transfers and Currencies

Yes, you can make transfers between accounts with different currencies. The system automatically applies the appropriate exchange rate for the transfer date:
text
- 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)

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.

Rules can be manually triggered in the following ways:
text
- 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]`
Rules with `active: true` run automatically when a family sync occurs (`Family::Syncer.perform_sync`):
text
**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`)
Rules are **not** triggered in these scenarios:
text
- **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"

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:

The popup only shows when **all** of these conditions are met:
text
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
Check these common reasons:
text
- **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

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.

text
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.
text
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.

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.

text
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.
text
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.

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.

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

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.

text
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.
text
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.

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.

text
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.
text
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.

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.

text
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
text
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.
text
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.

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

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:

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

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

text
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. 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.


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