mibudge

Build Status

mibudge

Personal budgeting service inspired by Simple Bank.

What is this?

Simple Bank had a budgeting model that let you divide your checking account balance into virtual sub-accounts called “goals” and “expenses.” When Simple shut down, nothing else replicated that experience well. mibudge is an attempt to rebuild and improve on that model.

The core idea

You have one or more bank accounts. Each account’s balance is divided into budgets – virtual sub-accounts that live entirely inside mibudge. Every dollar in the account is allocated to a budget, with an “Unallocated” budget catching anything not yet assigned. Budgets come in three types: Goal, Recurring (with an optional Associated Fill-up Goal sub-type), and Capped.

Transactions from the bank (purchases, deposits, transfers) are associated with a budget. Transactions may arrive as pending – recorded but not yet settled, with the final amount potentially differing from the pending amount (e.g. a gas station pre-authorization vs. the actual charge). A transaction represents one concrete bank event regardless of whether it is pending or posted. Most map to a single budget, but a transaction can be split – say, a store receipt that’s part groceries and part home improvement supplies.

Internal transactions track the movement of money between budgets within the same account. When you move $50 from “Dining Out” to “Groceries,” an internal transaction records the transfer with the source budget, destination budget, amount, and resulting balances on both sides. Internal transactions are write-once – to undo a transfer, you create a new internal transaction reversing it rather than deleting the original. In the UI, internal transactions are hidden by default to keep the transaction feed focused on real bank activity, but a toggle lets you show them when you want to see the full audit trail.

When the same real-world money movement appears on two accounts – for example, a credit card payment that shows as a debit on checking and a credit on the card – mibudge can link those transactions together. This cross-account linking is done opportunistically after import when both sides are present.

As money comes in (paychecks, etc.), it’s automatically distributed to budgets on a schedule, so that by the time a bill is due or a savings target arrives, the money is there.

Funding

Funding moves money from the per-account “Unallocated” budget into other budgets on a schedule. No real bank transfer happens – it is reallocation within mibudge’s virtual accounting.

The funding engine processes two event kinds: fund events (on each budget’s funding_schedule) and recur events (on each Recurring budget’s recurrence_schedule, refreshing it from its Associated Fill-up). Automatic funding fires via the schedule_funding_runs Celery beat task (every 30 minutes): fund events run in the [23:00, 23:30) local-time window on the fund date; recur events run in the [03:00, 03:30) local-time window on the recur date, giving the fill-up time to be fully funded before the sweep.

Automatic funding is designed for users with predictable income – they know their pay schedule and have calibrated their budget amounts accordingly. When a paycheck is still pending at 23:00 on a fund date, Unallocated may go temporarily negative; this is expected and self-corrects when the deposit posts. Users with irregular income may prefer to turn off automatic funding and use the “Run funding now” button on the account page, which triggers both fund and recur events immediately on demand.

The engine is safely re-runnable any number of times – a re-run on the same day transfers only the remaining gap (via the already_moved formula) without double-funding. Funding can also be triggered manually via POST /api/v1/bank-accounts/<id>/run-funding/.

See docs/funding.md for the full specification: per-type funding rules, the funded_amount invariant, Goal completion semantics, pause/archive behavior, the migration plan, and the test scenario matrix.

Budget types

All virtual sub-accounts are budgets. They differ by budget_type:

A Goal budget has a target amount and accumulates money on a funding schedule until the goal is reached. Once funded, it’s marked complete – the money sits there until you spend it or roll it into something else. Funding can be calculated automatically from a target date (mibudge works out how much each funding event must contribute given the time remaining and the current balance) or set as a fixed amount per funding event.

A Recurring budget is never truly complete. It has a recurrence schedule – monthly, quarterly, yearly, etc. Money builds up until the target is reached, then resets on the next cycle. Think rent, groceries, subscriptions.

A recurring budget can optionally have an Associated Fill-up Goal budget. The fill-up goal is where automatic funding deposits go – not directly into the recurring budget itself. Then, at the boundary between one recurrence cycle and the next:

  1. Money in the fill-up goal is transferred into the recurring budget, up to the budget’s target amount.
  2. Any excess that doesn’t fit (because the recurring budget wasn’t fully spent) stays in the fill-up goal.

For example: you have a monthly grocery budget with a $500 target. Throughout the month, automatic funding deposits accumulate in the fill-up goal. At the start of the cycle, the fill-up goal transfers $500 into the recurring budget. You spend $400 that month. When the cycle refreshes, the $100 left in the budget doesn’t need to move – the fill-up goal only needs to top the budget up to $500, so it contributes $400 instead of $500. That means the fill-up goal starts its next accumulation cycle with a $100 head start, needing only $400 in new funding to be ready for the following refresh. This also means you can have a fully funded recurring budget that is ready to spend while simultaneously accumulating funds in the fill-up goal for the next cycle.

A Capped budget tops itself up to a fixed cap amount on a funding schedule. Each funding event deposits a fixed amount (up to the cap) into the budget; when the balance is already at or above the cap, no funding occurs. As soon as spending brings the balance below the cap, the next scheduled funding event resumes automatically. Unlike a Goal (which is complete once funded) or a Recurring budget (which resets on a cycle), a Capped budget is perpetual – it is marked complete only while its balance equals or exceeds the cap, and reverts to active the moment any spending draws it down. Think of it as a reservoir that stays full as long as you keep it topped up: an emergency buffer, a standing household expense fund, or any amount you always want available.

Budget lifecycle

Budgets are never hard-deleted once they have transaction history. The rule is:

Accounts

mibudge supports multiple bank accounts – checking, savings, credit cards – each with their own set of budgets. Accounts can be shared between users (family members) or private to one user.

Invitations

Registration is closed – new users join mibudge by invitation only, via one of two flows:

Both flows email the invitee a single-use tokenized link (valid 7 days). Brand-new users set their first password through an emailed password-reset link after accepting – acceptance never issues credentials directly. Invitations are rate-limited per address (resend caps plus a rolling 30-day window) to prevent mailbox flooding. See docs/invitations.md for the full mechanism, rate limiting, and protections.

Users can later change their login email through a verified two-step flow with a 7-day revocation window – see docs/email-change.md for the flow and its security policy.

Supported platforms

The Django app itself is not tied to Docker: it runs from a checkout with uv, against SQLite or PostgreSQL. Running it that way (a process manager for the web server, Celery worker and Celery beat, plus Redis) is possible but not a documented or tested deployment.

Importing transactions

Bank data gets into mibudge through the command-line importers in importers/. They talk to any mibudge server over its REST API, so they need an API key (Settings -> API keys in the SPA), not access to the server. They are a separate client that will move to its own project once the API is stable; the command names below will stay the same. Nothing is installed outside the checkout: uv sync puts four commands in the checkout’s .venv/bin/:

uv run mibudge-import ~/Downloads/*.ofx       # OFX/QFX or BofA CSV statements
uv run mibudge-bofa-live                      # scrape Bank of America
uv run mibudge-bofa-saved saved/*.json        # replay saved BofA scrapes
uv run mibudge-backfill-budget --account "Personal Checking" --budget "Groceries"

From outside the checkout, use uv run --project <checkout> ... or the full path, <checkout>/.venv/bin/mibudge-import. Wherever they are run from, the importers read the .env at the root of the checkout; put the server there:

MIBUDGE_URL=https://your-mibudge-host
MIBUDGE_API_KEY_ONEPASSWORD_URL="op://Personal/mibudge/API key"

The API key itself is never read from .env: pass --api-key, or fetch it from 1Password or Vault. docs/importers.md covers every tool, flag and file they use.

Architecture

Backend: Django + DRF

Frontend: Vue 3 SPA

URL routing

Path Handled by Purpose
/ Django template Landing page
/accounts/ django-allauth Password reset (sign-in is the SPA’s /app/login/)
/admin/ Django admin Admin site (path set by DJANGO_ADMIN_URL)
/api/v1/ DRF JWT-authenticated REST API (v1)
/api/token/ TokenObtainPairView JWT access+refresh pair (cross-version)
/api/token/refresh/ CookieTokenRefreshView Silent token refresh via httpOnly cookie
/api/v1/schema/ drf-spectacular OpenAPI schema for v1 (YAML)
/api/v1/schema/swagger-ui/ drf-spectacular Swagger UI (interactive docs)
/api/v1/schema/redoc/ drf-spectacular ReDoc (interactive docs)
/app/* SpaShellView SPA shell; Vue Router handles all sub-routes

REST API resources

All resources are under /api/v1/. Full endpoint docs: docs/api.md · OpenAPI schema: docs/openapi.yaml (regenerate with make api-docs).

Resource Endpoint Notes
Users /api/v1/users/ List/update restricted to staff; /me/ available to all
Email change /api/v1/users/me/change-email/ Initiate a self-service email change (requires usable password)
Email confirm /api/v1/users/me/change-email/{token}/confirm/ Confirm the new address (no auth required – token is the credential)
Email revoke /api/v1/users/me/change-email/{token}/revoke/ Cancel a change within the 7-day revocation window (no auth required)
Banks /api/v1/banks/ Read-only reference data
Bank Accounts /api/v1/bank-accounts/ Scoped to account owners; sync-scrape action reconciles a full bank-side snapshot atomically
Co-owner invitations /api/v1/bank-accounts/{id}/invite/ etc. Send/list/cancel per account; public accept/decline at /api/v1/invitations/{token}/
Budgets /api/v1/budgets/ Scoped to account owners
Transactions /api/v1/transactions/ Scoped to account owners
Allocations /api/v1/allocations/ Budget assignments for transactions
Internal Transactions /api/v1/internal-transactions/ Budget-to-budget transfers

All resources except Banks and Users are scoped to bank account ownership – only members of an account’s owners M2M can access that account’s data. Staff and superuser status does not bypass ownership checks in the REST API.

Data management

Authentication

Two ways to authenticate against the REST API:

OAuth2 for registered third-party apps is planned as the next phase of machine authentication. See docs/authentication.md for the full details: token lifetimes, silent-refresh mechanics, passwordless accounts, the email-change security policy, and API-key management.

Project structure

mibudge/
  app/                    # Django project root (WORKDIR /app in container)
    config/               # Django settings, root URL conf, Celery app, DRF router
    users/                # Custom user app: model, views, URLs, allauth adapters, DRF API
    moneypools/           # Core budgeting app: models, signals, views
    tests/                # All tests, separated from app code by Django app
      config/             # Config and URL-level tests
      users/              # User app tests and model factories
      moneypools/         # Moneypools tests and model factories
    scripts/              # Container startup scripts (one per service: app, celery worker,
                          #   celery beat, flower). Selected via docker-compose `command:`
    templates/            # Django templates: SPA shell, allauth overrides
    static/               # Static files served by Django
  frontend/               # Vue 3 SPA
    src/                  # TypeScript source: components, Pinia stores, Vue Router, API client
    dist/                 # Production build output (collected by Django staticfiles)
  importers/              # Command-line importers (REST API clients; see docs/importers.md)
  docs/                   # Reference docs, OpenAPI schema, generated API reference
  examples/               # Worked API examples, one JSON file per endpoint
  deployment/             # Dev and prod docker env files, SSL certs, DB backups
  Dockerfile              # Multi-stage build: builder → dev → prod
  docker-compose.yml      # Local dev stack (Django, Postgres, Redis, Celery)
  Makefile                # Dev commands (`make help` for full list)

Docker

A single multi-stage Dockerfile produces both dev and prod images:

The container’s WORKDIR is /app. The dev docker-compose mounts ./app:/app:z so code changes are reflected immediately. The venv lives at /venv (outside the mount) so it isn’t shadowed.

Startup scripts in app/scripts/ use wait-for-it for service readiness. The docker-compose command: selects which script runs – start_dev.sh for local dev (uvicorn –reload), start_app.sh for production (gunicorn with uvicorn workers).

Development

Prerequisites: Docker, uv (it installs the Python version in .python-version), pnpm, and mkcert with mkcert -install run once.

# Start all services (builds images, runs in background)
make up

# View logs
make logs

# Shell into the django container
make shell

# Django management shell (shell_plus)
make manage_shell

# Run migrations (in container)
make migrate

# Create a superuser to sign in with at /app/login/:
# admin@example.com / testpass1234
make createadmin

# Make new migrations (runs locally via uv)
make makemigrations

# Run tests (locally via uv, not in Docker)
make test

# make test runs on SQLite (including the concurrency tests).  To also
# run the concurrency and Postgres-only tests against the compose
# Postgres on localhost:6432 (user and password debug/debug);
# --wait returns once the Postgres healthcheck passes:
docker compose up -d --wait postgres
MIBUDGE_TEST_DATABASE_URL=postgres://debug:debug@localhost:6432/mibudge \
    uv run pytest -m "postgres or concurrency" -v

# Run linter + formatter + mypy (locally via uv)
make lint

# See all available commands
make help

What gets built, and where it goes

Inside the checkout and Docker’s storage:

Made by What Where
make env (run by make up, make test, make lint) this host’s settings .env (never overwritten once it exists)
make certs (run by make up) mkcert TLS certificate and key deployment/ssl/<host>-ssl_{crt,key}.pem
make up / make build Docker images mibudge:dev, mibudge:<version>-dev, mibudge:latest, mibudge:<version>
make up PostgreSQL and Redis data Docker volumes mibudge_local_postgres_data, mibudge_local_redis_data (make purge deletes them)
make up (creates and mounts it; nothing writes there automatically) a place for dev PostgreSQL dumps, the postgres container’s /backups deployment/db_backups/
make test, make lint, uv sync Python virtualenv, including the importer commands .venv/
pnpm install frontend dependencies frontend/node_modules/
pnpm build production SPA bundle frontend/dist/
manage.py collectstatic collected static files app/staticfiles/
uploaded images and documents media app/media/
manage.py with no DATABASE_URL set SQLite database app/db.sqlite3

The dev .env sets DATABASE_URL to the compose PostgreSQL, so the SQLite default applies only when nothing sets it. The importers’ own files are listed in docs/importers.md.

Outside the checkout, the tools keep their usual shared state, each in the place its own command prints:

What Where
the Python that uv installs for .python-version uv python dir
uv’s package cache uv cache dir
pnpm’s package store pnpm store path
the mkcert CA (mkcert -install also adds it to the system trust store) mkcert -CAROOT

Simulated bank data

A fresh development instance has no bank data. The bank activity simulator makes some: it acts as a fake bank, generating realistic transactions for simulated bank accounts and feeding them through the same path as the live bank importer. The simulated bank accounts belong to an existing user, here the one make createadmin creates:

make createadmin
uv run python app/manage.py simulate_bank_activity init sim.yaml --owner admin@example.com
uv run python app/manage.py simulate_bank_activity fill sim.yaml

Sign in at /app/login/ as admin@example.com / testpass1234 to see them.

That gives three months of history at three banks. To watch budgets fill and reset as time passes, start the clock in the past and move it forward a day or a week at a time:

uv run python app/manage.py simulate_bank_activity advance sim.yaml 1d

See docs/simulator.md for walkthroughs, the scenario file reference and what to avoid while a simulation is running.

Frontend development

cd frontend
pnpm install      # Install dependencies
pnpm dev          # Start Vite dev server (port 5173, HMR enabled)
pnpm build        # Production build → frontend/dist/
pnpm type-check   # Run vue-tsc

Set DJANGO_VITE_DEV_MODE=True in your .env (or rely on the DEBUG=True default) so Django proxies asset requests to the Vite dev server during development.

Dependency management

make uv-sync          # Sync .venv with uv.lock
make uv-lock          # Update uv.lock from pyproject.toml
make uv-add PACKAGE=x # Add a dependency
make uv-add-dev PACKAGE=x  # Add a dev dependency
make uv-upgrade       # Upgrade all dependencies

Environment

Local development uses one .env at the repo root, created for each host from deployment/dot-env.dev by make up (or make env). Tools on the host and the docker compose services both read it; compose overrides the service URLs with docker-internal hostnames. Each host also gets its own mkcert TLS certificate in deployment/ssl/. See deployment/README.md for the details, including running from a checkout shared between machines, and deployment/production/README.md for production.

Email

mibudge uses django-anymail as its email backend. The active backend is selected by the DJANGO_EMAIL_BACKEND env var, which defaults to anymail.backends.smtp.EmailBackend (plain SMTP submission).

Local dev (Mailpit)

No configuration needed. The default SMTP backend points at the Mailpit container (mailpit:1025 in Docker, localhost:1025 from the native shell). Mailpit catches all outbound mail and exposes it at http://localhost:8025.

Production: own SMTP server

Set these env vars:

DJANGO_DEFAULT_FROM_EMAIL=MiBudge <noreply@yourdomain.com>
DJANGO_SUPPORT_EMAIL=support@yourdomain.com
SITE_DISPLAY_NAME=MiBudge
SITE_URL=https://yourdomain.com
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587                 # default
EMAIL_HOST_USER=user@example.com
EMAIL_HOST_PASSWORD=secret
EMAIL_USE_TLS=True             # default

DJANGO_EMAIL_BACKEND can be left at its default (anymail.backends.smtp.EmailBackend).

SITE_DISPLAY_NAME appears in email subjects and headers. Set it to something distinct if you run multiple instances (e.g. MiBudge [staging]) so users can tell them apart. DJANGO_SUPPORT_EMAIL is shown in notification emails as the contact address.

Production: API-based provider (Postmark, Mailgun, SendGrid, SparkPost)

Set DJANGO_EMAIL_BACKEND to the anymail backend for your provider, supply the corresponding token, and set the sender/site variables as above:

Provider DJANGO_EMAIL_BACKEND Token env var
Postmark anymail.backends.postmark.EmailBackend POSTMARK_SERVER_TOKEN
Mailgun anymail.backends.mailgun.EmailBackend MAILGUN_API_KEY, MAILGUN_SENDER_DOMAIN
SendGrid anymail.backends.sendgrid.EmailBackend SENDGRID_API_KEY
SparkPost anymail.backends.sparkpost.EmailBackend SPARKPOST_API_KEY

The SMTP settings (EMAIL_HOST etc.) are ignored when using an API-based backend.

Documentation

Doc Contents
docs/api.md REST API reference (generated from OpenAPI schema)
docs/spa/ The Vue SPA: architecture, state, screens, styling and testing
docs/authentication.md Authentication: JWT two-token pattern, API keys, machine-credential policy
docs/invitations.md Co-owner and admin invitations: mechanism, rate limiting, protections
docs/email-change.md Self-service email change: flow, revocation window, security policy
docs/funding.md Budget funding engine: rules, invariants, test scenarios
docs/importers.md Bank statement import tools
docs/management-commands.md Django management commands
docs/simulator.md Bank activity simulator: development data and simulated-time walkthroughs
docs/releasing.md Versions, release tags, published images, making and rolling back releases
app/notifications/README.md Notification service: kind registration, digest mechanics, adding channels

License

BSD