Skip to content
LanternwrightPublic

About

Self-hosted household statement splitter: import a card or bank statement, swipe each charge to whoever owes it, settle up in the fewest payments. One container, no bank logins, no telemetry.

Topics

Resources

Contributing

Security policy

Accessibility

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Weregild

Split shared card and bank statements by swiping, then settle up.

Weregild is a household statement splitter for couples, roommates and families who share money. Import the statement you already have, swipe each charge to whoever owes it, and get the fewest payments that square everyone up. It runs on your own server. The name is the Old Norse debt-compensation payment.

A 30-second walk through the sample household: the home screen, six card charges swiped to Ada, Bo or flagged, one merchant turned into an automation, then a settle-up cycle showing who pays whom.

Weregild home screen for a made-up two-person household: what Ada is owed this cycle, the one payment that settles it and this cycle's card charges and bills. The swipe deck: one card charge at a time, with a labeled button for each person, split, flag and undo, and the keyboard shortcuts shown. Importing a bank's CSV export in dark mode: Weregild guesses which column is the date, description and amount, and previews the rows before anything is saved. Settling a cycle: each person's share of the card and the bills, and the fewest payments that square everyone up.

Every screen here is the built-in demo household (manage.py demo); the names, merchants and amounts are invented. just shots rebuilds them, and docs/media/manifest.json lists each file with its alt text.

Install

With Docker and Docker Compose 2.24 or newer:

mkdir -p weregild/data && cd weregild
curl -fsSL -O https://github.com/lanternwright/weregild/releases/latest/download/compose.yaml
docker compose up -d        # then open http://localhost:8000

The first visit creates your login in the browser; there is nothing to set up beforehand. To open it from other devices, list the host names you use in ALLOWED_HOSTS (see .env.example). The app runs as uid 1000; if your account's id is different, see Deploy.

Highlights

  • No typing expenses. Import PDF statements from a few US card issuers, OFX/QFX downloads, or any bank's CSV through a column mapper that learns the layout once.
  • Swipe to split. Left and right for the two people on the card, up to split, down to flag, with undo. The deck works from the keyboard alone, and automations sort a merchant for good.
  • Settle in the fewest payments. Any number of people, shared bills with custom shares, carryover between cycles, and pay links that open your payment app. Weregild never moves money.
  • Leave whenever you like. Download everything as JSON or your transactions as CSV, restore an export elsewhere, or delete it all.
  • Works like an app. Install it to a phone's home screen; light and dark.
  • Homelab friendly. One container, SQLite, a /healthz check, and optional trusted-header SSO for Authentik, Authelia and similar proxies.

Privacy: no bank logins and no telemetry, and statement files are deleted as soon as they are read. Anything that talks to the outside world (LLM categorization, the mailbox watch, notifications) stays off until you turn it on. PRIVACY.md has the details.

Made by Lanternwright. Questions and bug reports: support@lanternwright.com.

What it does

Split the card (the original job):

  • Ingest statements: Capital One Savor PDFs, OFX/QFX, and the CSV export of any bank, read through a column mapper that learns each account's layout once (two-stage pipeline: parsers normalize to rows; import dedupes by account+date+description+amount). Cardholder is metadata only; responsibility is decided by swiping.
  • Swipe each charge: the two horizontal gestures go to whichever members hold the left/right slots, up opens a split sheet (any subset, evenly or by % or by $), down flags for discussion. Everyone else is a one-tap chip under the card, so the fast path stays fast at any household size. Keyboard + undo.
  • Automations: attribute a whole merchant to one person once; every past and future charge auto-assigns. Suggested automations when you repeat yourself.
  • Fixed monthly costs: mortgage/HOA/utilities with per-month amount, payer, and a per-member split (defaults to even); "on card" flag to avoid double-counting; month-open prompt.
  • Settle per cycle: per-member balances collapsed into the fewest payments, mark settled/paid, running carryover across unpaid cycles (also collapsed).

Whole-finances layer (multi-account):

  • Accounts: credit cards, checking, savings, investments; every txn belongs to one. Owner / cardholder / responsibility are three distinct fields.
  • Classification: each bank txn is typed (spend / card_payment / transfer / income / fixed_cost / investment_contribution) by rules + heuristics; a review center catches the rest.
  • Reconciliation: matches card-payment bank txns to card statement payments (equal amount, 5-day window); ambiguous matches go to review.
  • Categorization: editable taxonomy; precedence override → heuristic → an optional Claude batch pass (feature-flagged; the app is fully functional without it).
  • Insights: recurring/subscription detection, category trends + drift, top merchants, fee + duplicate detection, all in inline-SVG charts.
  • Budgets / cash flow / savings: per-category caps with pacing, monthly cash flow, savings rate vs target, gap analysis.
  • Net worth: manual balance snapshots for investment accounts (kept off the transaction pipeline), timeline chart.

Statement watch (optional):

  • Mailbox poller: reads "your statement is ready" mail over read-only IMAP (presets for Gmail, Fastmail and iCloud Mail, or any IMAP server) and raises a StatementAlert. Weregild never downloads the statement itself; every institution puts those behind a login, so the alert is a nudge with whatever the email exposed (last4, balance, minimum, due date).
  • Notification: a dismissable banner at the top of the app linking to /upload/, plus any of a generic JSON webhook, ntfy, Discord or email.
  • Matchers are per-issuer and narrow, declared in ledger/services/mailwatch.py. Capital One sends "Your $50.00 statement credit is on the way!" every month; a loose subject contains "statement" rule would fire on it, so each matcher carries an explicit reject list. Currently covers Capital One Savor, Chase, Raymond James and Arvest.
  • Off by default (STATEMENT_WATCH_ENABLED). Enabling it adds an app password and an outbound IMAP connection, so the app is no longer strictly "on-host except Anthropic". That tradeoff is deliberate and opt-in.

Stack

Django 5 + HTMX + Alpine.js, SQLite, WhiteNoise, gunicorn. Server-rendered and mobile-first, charts are hand-rolled inline SVG (no JS chart lib). Statement parsing via pdfplumber (PDF) and ofxparse (OFX/QFX). Optional LLM categorization via the Anthropic SDK. Packaged as a single Docker container. All financial data stays on-host; the only optional outbound calls are the Anthropic API for categorization and the statement-watch IMAP poll, both off by default.

Local development

python -m venv .venv && . .venv/bin/activate
pip install -r requirements-dev.txt   # the app plus what the tests need
export DEBUG=True               # production settings refuse the dev SECRET_KEY
python manage.py migrate
python manage.py seed          # creates an empty household + categories
python manage.py runserver

Open the app and it walks you through the first run in the browser: create the owner login at /welcome/ (open only while no login exists, so no createsuperuser), set up the household at /household/, then import a first statement at /welcome/import/. seed is optional: the first run makes the same defaults. For an unattended install, seed people directly instead:

python manage.py seed --member "Ada:ada" --member "Bo" --card-payer Ada

Name:username gives that person a login (password from WEREGILD_<NAME>_PASSWORD, default changeme); a bare Name creates a member with no login, right for anyone who shares the money but never opens the app.

Try it with demo data

export DEBUG=True
python manage.py migrate
python manage.py demo                          # Ada and Bo, a couple sharing one card
python manage.py demo --scenario roommates --reset   # Cy, Dee and Eli splitting rent
python manage.py runserver                     # sign in as ada / demo

The demo builds three closed months of card and bank statements relative to today: the oldest cycle settled and paid, the middle one open, the newest partly swiped so the review deck has cards. Bills, automations, savings goals, budgets, net worth and paystubs are filled in too. Every name, merchant, amount and account number is invented, and the generator is seeded, so rebuilds look the same. One scenario per database (an instance shows one household).

manage.py demo refuses to run against a database holding anything it did not create; --force overrides that and deletes everything in the database first.

Public demo: set DEMO_MODE=true. The app shows a banner and the demo logins on the sign-in page, and refuses uploads, deletions, household and settings changes, the admin site and outbound calls, while swiping, splitting, automations and settling keep working. Rebuild the data on a timer, e.g. an hourly python manage.py demo --reset.

just shots rebuilds the store screenshots and the demo clip from a throwaway demo database; --today YYYY-MM-DD on manage.py demo pins the dates.

Sample data on your own install: on a new install with nothing in it, the first-import page offers "Explore with sample data first". It loads the same couple with your login playing Ada (no other logins are created), shows a banner on every page, pauses imports, and one click removes it all, keeping your login.

Household model

A Household has N Members. A member is not a login: someone who never signs in still needs to appear in a split. Anywhere a person shows up it's a Member reference, and any "who owes what fraction" is a set of share rows that sum to 1, so a charge can be split across any subset in any proportion.

Every top-level table carries household, so making this multi-tenant later is a query filter rather than another migration.

Settlement model

Everything reduces to a signed per-member ledger:

contribution   = payments + cash back they put in
                 (+ the carried balance, for whoever fronts the card)
responsibility = their share of every assigned charge
net            = contribution - responsibility

Positive net means the group owes them. Nets always sum to zero. Those balances are then collapsed into the fewest transfers that clear everyone (greedy largest-debtor to largest-creditor, at most N-1 payments). Fixed costs net the same way: paying one is a contribution, owing a share of one is a responsibility.

With two people this reduces to exactly the old behavior: the non-payer owes the payer their full card responsibility, less their own payments and cash back. Minimizing transfers does mean someone can be told to pay a person they never transacted with; that's inherent to the approach, not a bug.

See ledger/services/settlement.py and ledger/services/money.py (split_amount uses largest-remainder rounding so N shares always sum back to the exact cent).

Deploy

mkdir -p data               # first time only, so the folder belongs to you
cp .env.example .env        # optional; set ALLOWED_HOSTS to the name/IP you use
docker compose up -d --build
# open http://localhost:8000

To run a published image instead of building, set WEREGILD_IMAGE (for example ghcr.io/lanternwright/weregild:0.9.0) in .env and run docker compose up -d; each GitHub release also attaches a ready-made compose.yaml pointed at its image.

The first visit creates the owner login in the browser at /welcome/, which stays open only until a login exists; then household setup and a guided first import. The default compose.yaml is standalone: it publishes port 8000 (change it with WEREGILD_PORT) and uses password login. Passwords chosen in the app need 8 or more characters and can't be common, all digits, or close to the username. The SQLite DB and a SECRET_KEY generated on first boot live on the mounted ./data volume and never leave the host. Uploaded statement files are not kept at all unless you ask (see Your data). Back up by copying ./data.

Health and version. GET /healthz answers {"status": "ok", "version": "..."} once the app can reach its database and 503 when it can't. It needs no login and shows nothing else, so point any uptime monitor at it; the image's Docker HEALTHCHECK uses it too. docker compose exec weregild python manage.py version prints the version, which is also on the More and Settings pages.

Upgrades. Before an upgrade applies database changes, the container copies the database to ./data/backups/ (the newest 3 copies are kept, readable by their owner only; "Delete all data" removes them too). Since 0.9.0 the container runs as a regular user (uid 1000) instead of root. A ./data folder created by an older version belongs to root, and the container stops with a message saying so. Fix it once with sudo chown -R 1000:1000 ./data, or start the container one time as root (WEREGILD_USER=0:0 in .env, or docker run --user 0): it hands the folder to uid 1000 and then drops to that user for everything else. If your own account is not uid 1000, set WEREGILD_USER=<uid>:<gid> in .env so the files stay yours. Build with --build-arg APP_UID=... --build-arg APP_GID=... to run as a different account.

Production settings fail closed: with DEBUG=False the app refuses to start on an empty or placeholder SECRET_KEY, and ALLOWED_HOSTS defaults to loopback only (no wildcard), so list the host names you actually use.

To deploy to another machine over SSH, copy deploy/deploy.env.example to deploy/deploy.env, set DEPLOY_HOST and PUBLIC_URL, then just deploy. It rsyncs the repo, writes a first-run .env with a random SECRET_KEY (and no people: the first visit creates the owner login at /welcome/), and runs docker compose up --build. It rsyncs with --delete, so do not patch files by hand on the host.

Single sign-on (optional)

Weregild can trust a username header from an SSO reverse proxy (Authentik, Authelia, oauth2-proxy, or forward auth in Traefik, Caddy or nginx). It is off by default and the middleware does nothing until you enable it:

TRUSTED_HEADER_AUTH=true
TRUSTED_HEADER_NAME=Remote-User          # Authentik: X-Authentik-Username
TRUSTED_HEADER_PROXIES=172.16.0.0/12     # accept the header only from the proxy

Only enable it when the proxy is the sole route to the container and strips any copy of the header a client sends; otherwise anyone who reaches the port can claim any identity. The header only signs in accounts that already exist (it never creates users), password login keeps working as a fallback, and turning the feature off ends header-created sessions immediately. manage.py check warns when SSO is on without TRUSTED_HEADER_PROXIES.

deploy/compose.homelab.yaml is a ready-made variant for Authentik: no published port, the proxy's Docker network, and SSO switched on. If the proxy is down, add ports: ["127.0.0.1:9002:8000"] to it and log in with a password over an SSH tunnel.

Portal tile (optional): deploy/portal-tile.html is a tile for a static homelab dashboard page.

CSV exports from any bank

Upload the CSV your bank's website exports. The first time, Weregild works out the columns itself: the date, the description, and how money is written (one signed amount column either way round, separate money-out and money-in columns, or an amount plus a debit/credit column), including day-first dates, decimal commas, summary lines above the header and files with no header row. It then shows a sample of the file and a live preview of money out and money in, so you can confirm or fix the guess before anything is imported.

The confirmed mapping is saved on the account, and the next file with the same columns imports in one step. A file whose columns changed goes back to the mapper; the account page shows the saved columns and can forget them. Weregild's own layout (trans_date, post_date, description, amount, cardholder) always imports directly. An upload waiting on the mapper is visible only to the login that uploaded it and is deleted after a day if nobody finishes it.

Your data

Statement files are deleted after import by default. Weregild reads each upload, saves the transactions, and keeps only the file's name, size and SHA-256 fingerprint; the file itself is never written to disk outside a temporary file. The fingerprint is how uploading the same file twice is caught ("Already imported on ... Nothing changed.") before it is parsed again. A CSV waiting on the column mapper is held until you import or cancel it, or for a day at most. Set KEEP_UPLOADED_STATEMENTS=true to keep the originals instead; each statement's page then offers a download, and Settings shows how many files are kept and can delete them (so can python manage.py purge_statement_files). Deleting a statement deletes its file too.

Settings > Your data has everything needed to leave, back up, or start over:

  • Download everything: one JSON file with every table (accounts, statements, transactions, splits, bills, automations, goals, snapshots, paychecks) and the logins, without their passwords. From a shell, python manage.py export_data -o weregild.json writes the same file, readable by its owner only, which suits a scheduled backup.
  • Transactions as CSV: one row per transaction with who is responsible and the exact shares. Text that starts like a spreadsheet formula is prefixed with an apostrophe so it can never run as one.
  • Delete all data: needs an admin login and the typed phrase delete everything. It removes every household record, every uploaded statement file and every other login; your own login is kept so you can start over.
  • Restore an export: offered on an instance with no financial data yet (a fresh install, or one just emptied). From a shell: python manage.py import_data weregild.json [--replace]. Logins in the file that don't exist here are created without a password (exports never carry one); set it with python manage.py changepassword <name>.

Statement watch

Enable in .env (see .env.example for the full block):

STATEMENT_WATCH_ENABLED=True
STATEMENT_WATCH_IMAP_PRESET=gmail        # or fastmail, icloud, generic
STATEMENT_WATCH_IMAP_USER=you@example.com
STATEMENT_WATCH_IMAP_PASSWORD=<app password>
NOTIFY_NTFY_TOPIC=<an unguessable topic> # optional; see Notifications

The password must be an app password: Gmail, Fastmail and iCloud all reject the account password over IMAP. The generic preset takes STATEMENT_WATCH_IMAP_HOST (and optionally _PORT, _SECURITY=starttls, _MAILBOX).

Notifications

New alerts always show as a banner in the app. To hear about them elsewhere, configure any of these in .env (all optional, several can run at once):

Target Settings
Generic JSON webhook NOTIFY_WEBHOOK_URL
ntfy NOTIFY_NTFY_TOPIC, optional NOTIFY_NTFY_SERVER, NOTIFY_NTFY_TOKEN
Discord NOTIFY_DISCORD_WEBHOOK_URL (the older STATEMENT_WATCH_WEBHOOK_FILE still works)
Email NOTIFY_EMAIL_TO plus the EMAIL_* SMTP settings

Secrets accept a *_FILE variant for Docker secrets. Notifications leave your server, so dollar amounts stay out of them unless NOTIFY_INCLUDE_AMOUNTS=true. Settings > Notifications lists what is configured (without secrets) and sends a test message.

docker compose exec weregild python manage.py poll_statements --dry-run
docker compose exec weregild python manage.py poll_statements --since 120

Dedupe is on the email's Message-ID, so the poller is safe to run as often as you like and will never announce the same statement twice. Install the timer:

sudo cp deploy/weregild-statements.* /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now weregild-statements.timer

Runs 08:15 and 20:15. To add an institution, append a Matcher to MATCHERS in ledger/services/mailwatch.py: sender fragment, subject fragments, and a subject_never list for any marketing mail that shares the wording.

Easter egg

Somewhere in here is a hidden game: Whose Charge Is It?! Guess whose card each mystery charge landed on, build a streak. Find it with the Konami code (↑ ↑ ↓ ↓ ← → ← → B A) or by tapping the rune logo seven times.

Support

Weregild is made by Lanternwright. For questions, bug reports and feature requests, write to support@lanternwright.com. Security problems go to the same address, never to a public issue (see SECURITY.md). What Weregild sends and stores is in PRIVACY.md; accessibility is in ACCESSIBILITY.md; how to help is in CONTRIBUTING.md.

Weregild keeps records for your household. It is not financial, tax or legal advice, and it never moves money: pay links open your payment app with the amount filled in, and you decide whether to send it.

License

Weregild is free software under the GNU Affero General Public License v3.0 (LICENSE). If you run a modified version for other people over a network, the license asks you to offer them your source. Dependencies and their licenses are listed in THIRD_PARTY_NOTICES.md.

Tests

just test        # 764 Django unit tests (settlement, money, parsers, the CSV
                 # mapper, ingest, first run, export/restore/delete, statement
                 # retention, health check, packaging and release files,
                 # accessibility structure, privacy and copy checks, SSO,
                 # notifications, demo data, privacy guard)
just e2e         # 37 Playwright browser tests on three throwaway servers: the
                 # regular app, DEMO_MODE with the demo household, and an empty
                 # install (first run through import, export, delete, restore),
                 # plus axe-core accessibility checks in light and dark

ledger/tests/test_privacy_guard.py fails the suite if a known piece of personal data (stored only as salted digests) reappears anywhere in the tree. npx playwright test -c playwright.smoke.config.js drives a running container through first run (CI's container smoke test).

About

Self-hosted household statement splitter: import a card or bank statement, swipe each charge to whoever owes it, settle up in the fewest payments. One container, no bank logins, no telemetry.

Topics

Resources

Contributing

Security policy

Accessibility

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages