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.

Architecture

Backend: Django + DRF

Frontend: Vue 3 SPA

URL routing

Path Handled by Purpose
/, /accounts/, /admin/ Django templates Login, auth, admin
/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
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

Auth: JWT two-token pattern

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)
  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, Python 3.13+, pnpm

# 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

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

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

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

# See all available commands
make help

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 dev uses two env files with distinct purposes:

File Read by Contains
.env (repo root, gitignored) Local shell — uv run manage.py, pytest, linters, make api-schema localhost URLs with published ports
deployment/local-dev-docker.env (gitignored) docker-compose (env_file:) Docker-internal hostnames and port numbers

The split lets you run app/manage.py directly from the native shell without docker-execing into a container, while docker-compose services still talk to each other over the docker network.

Published ports (docker → localhost):

Service docker-internal localhost
PostgreSQL postgres:5432 localhost:6432
Redis redis:6379 localhost:7379
Mailpit SMTP mailpit:1025 localhost:1025

First-time setup:

# Create .env from the template (only needed once; edit after if needed)
make env

make env generates both files from their committed templates if they do not already exist: deployment/dot-env.dev.env, and deployment/dot-env.docker-devdeployment/local-dev-docker.env. The defaults in both templates work without any edits for a standard local dev setup.

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/funding.md Budget funding engine: rules, invariants, test scenarios
docs/importers.md Bank statement import tools
docs/management-commands.md Django management commands
docs/UI_SPEC.md Frontend screen inventory and component breakdown
app/notifications/README.md Notification service: kind registration, digest mechanics, adding channels

Detailed documentation for user account management (co-ownership invitations, email change flow, security policy) will be added to docs/ as those features stabilise.

License

BSD