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.
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.
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:8000The 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.
- 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
/healthzcheck, 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.
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 loosesubject 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.
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.
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 runserverOpen 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 AdaName: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.
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 / demoThe 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.
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.
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).
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:8000To 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.
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.
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.
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.jsonwrites 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 withpython manage.py changepassword <name>.
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).
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) |
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 120Dedupe 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.timerRuns 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.
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.
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.
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.
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 darkledger/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).
