Personal budgeting service inspired by Simple Bank.
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.
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 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.
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:
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.
Budgets are never hard-deleted once they have transaction history. The rule is:
archived_at. If the budget has an associated fill-up goal, that is archived and drained first. Archived budgets retain their full transaction history and can be retrieved via the API with archived=true.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.
/app/)fetch wrapper (src/api/client.ts) – no third-party HTTP clientfrontend/dist/ collected by Django staticfiles for production| 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 |
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.
docs/importers.md – REST API tools for importing bank statements and backfilling budget allocations (no server access required)docs/management-commands.md – Django management commands for service operations, backup/restore, and data correction (requires server access)app/notifications/README.md – pluggable notification service: kind registration, the notify() API, email digest mechanics, and how to add new kinds or channelsAuthorization: Bearer headerhttpOnly; Secure; SameSite=Strict cookie, never readable by JSROTATE_REFRESH_TOKENS = True, BLACKLIST_AFTER_ROTATION = True – each refresh call resets the 14-day clock/app/login/. It posts
username+password to POST /api/token/ (CookieTokenObtainPairView), which
returns the access token in the JSON body and sets the refresh token as the
httpOnly; Secure; SameSite=Strict cookie.main.ts calls
authStore.refresh() before installing the router. If the refresh cookie is
still valid, the SPA becomes authenticated before the first route guard runs
and returning users skip the login screen entirely.POST /api/token/refresh/ – the browser sends the httpOnly cookie
automatically, returning a new access token and rotating the refresh cookie./accounts/ for password reset only (/accounts/password/reset/); it is not part of the SPA login path. The allauth templates are plain Django-rendered pages – they do not use the SPA shell.has_usable_password() == False). They can use the app normally but cannot use the change-password or change-email features until they set a password via /accounts/password/reset/. Both forms detect this state via the has_usable_password field on GET /api/v1/users/me/ and prompt the user accordingly.users/email_change.py for the security policy.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)
A single multi-stage Dockerfile produces both dev and prod images:
/venv using uvThe 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).
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
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.
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
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-dev → deployment/local-dev-docker.env. The defaults in both templates work without any edits for a standard local dev setup.
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).
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.
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.
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.
| 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.
BSD