A lightweight, offline-first contribution and financial ledger for small organizations — clubs, church groups, associations — that need to track member dues and general income/expenses without standing up a database server or paying for hosting. Runs entirely on a local SQLite file behind a single shared admin login.
- Features
- Screenshots
- Tech Stack
- Quick Start
- Configuration
- Usage
- Project Structure
- Data Model
- API Reference
- Testing
- Migrations
- Backups
- Security Notes
- Deployment Notes
- License
- Member management — add, rename, and remove members
- Dues tracking — log a member's payment against a coverage period (e.g. "October 2024 → May 2025"); adjacent periods merge automatically so a member's history stays as a clean set of contiguous ranges
- General ledger — record any credit or debit (dues, equipment, offerings, etc.) with running-balance calculation
- Dashboard — at-a-glance balance, total dues, credits, debits, recent transactions, and top contributors
- Search & filter — query dues and ledger records by member, keyword, date range, or type; print or export results to CSV
- Reports — monthly or all-time financial reports, printable
- Single-admin auth — session-based login gated behind one password; every API endpoint, reads included, requires an authenticated session
- Exact money math — amounts are stored as integer pesewas (cents), not floating-point, so totals never drift
- Audit trail — every create/update/delete is recorded in
audit_logwith before/after values, so nothing is silently lost to an edit - Backups — one-command, verified SQLite backups with rotation and a deliberately separate, CLI-only restore path
- Local SQLite database — no external database server, auto-created on first run
- Offline-first — designed to run on a single machine on a local network, no internet dependency
| Login | Dashboard |
|---|---|
![]() |
![]() |
| Members | Ledger |
|---|---|
![]() |
![]() |
- Backend: Python, Flask
- Database: SQLite (via the standard library
sqlite3module) - Frontend: vanilla HTML, CSS, and JavaScript — no build step, no
framework, no
npm install. This is a deliberate choice: the app is meant to be cloned and run with nothing more than Python, matching its offline/zero-infrastructure goal. - Testing: pytest
- Python 3.9 or later
- pip
git clone https://github.com/niiomar/LEDGER.git
cd LEDGERcp .env.example .envThen edit .env and set a real SECRET_KEY and ADMIN_PASSWORD (see
Configuration — do not run this in production with
the example defaults).
Windows: double-click run_windows.bat, or from a terminal:
.\run_windows.batmacOS / Linux:
./run_mac_linux.shEither script creates a virtual environment on first run, installs
requirements.txt, and starts the server. Or do it manually:
python -m venv venv
source venv/bin/activate # venv\Scripts\activate on Windows
pip install -r requirements.txt
python app.pyThen open http://127.0.0.1:5000 and log in with admin and your
ADMIN_PASSWORD. Every view and every API endpoint requires that login —
there is no anonymous read access.
All configuration lives in .env (see .env.example), loaded via
python-dotenv:
| Variable | Description | Default |
|---|---|---|
SECRET_KEY |
Signs Flask's session cookie. Must be a long random string in any real deployment — anyone with this value can forge a valid session. | dev-only-change-me |
ADMIN_PASSWORD |
Password for the single admin account. |
admin |
DB_PATH |
Path to the SQLite database file. Created automatically if it doesn't exist. | gmm_media.db |
Generate a strong SECRET_KEY with:
python -c "import secrets; print(secrets.token_hex(32))"The app prints a warning on startup if SECRET_KEY or ADMIN_PASSWORD
are still at their insecure defaults.
- Log in with
adminand your configured password. - Log Dues — record a member's payment against a period; the amount auto-calculates from the selected period at a fixed monthly rate, and the system prevents gaps by locking the start period to right after their last paid month.
- Ledger — record any other credit or debit (batteries, offerings, equipment, etc.). Every dues payment also lands here automatically as a linked credit, so the ledger is always the single source of truth for cash totals. A credit entered before that automatic linking existed shows a link icon — click it to attribute it to the member and coverage period it actually paid for.
- Query Records — filter dues/ledger by member, keyword, date range, or type; print the results or export them to CSV.
- Members — add members, click a name to rename it inline, or remove a member (this also removes their dues and linked ledger entries).
- Reports — generate a monthly or all-time report with summary totals, a dues coverage table, and the general ledger for that period.
All of the above — including just viewing the dashboard, ledger, dues,
and reports — requires being logged in as admin. There is no
public/anonymous read mode.
LEDGER/
├── app.py # Entrypoint: creates the Flask app, registers blueprints
├── db.py # Connection handling + schema (init_db)
├── money.py # Cedis ↔ pesewas conversion, amount validation
├── periods.py # "Month YYYY" period parsing/formatting
├── audit.py # log_audit() — writes to audit_log
├── backup.py # Backup/restore (also runnable as a CLI)
├── auth.py # login_required decorator + CSRF check
├── config.py # Loads settings from .env
├── routes_members.py # Blueprint: /api/members*
├── routes_dues.py # Blueprint: /api/dues*, dues-period merging
├── routes_ledger.py # Blueprint: /api/ledger*
├── routes_reports.py # Blueprint: /api/reports/*
├── routes_session.py # Blueprint: /api/login, /api/logout, /api/session
├── routes_admin.py # Blueprint: /api/audit, /api/admin/backup(s)
├── seed_demo.py # Optional fictional demo data for a fresh DB
├── requirements.txt # Runtime dependencies
├── requirements-dev.txt # + pytest, for running the test suite
├── pytest.ini
├── run_windows.bat # One-click setup + run (Windows)
├── run_mac_linux.sh # One-click setup + run (macOS/Linux)
├── migrations/
│ ├── 001_data_corrections.py
│ ├── 002_link_ledger_to_dues.py
│ └── 003_money_to_minor_units.py
├── backups/ # Created by backup.py; gitignored
├── static/
│ ├── css/style.css
│ ├── js/app.js
│ └── images/
├── templates/
│ └── index.html # Single-page app shell
├── tests/
│ ├── conftest.py
│ ├── test_app.py
│ └── test_admin.py
└── docs/screenshots/
Four tables, all created automatically by init_db() on first run:
members (id, name)
│ 1
│
│ *
dues (id, member_id, amount, period_from, period_to)
│ 1
│
│ *
ledger (id, type, description, amount, date, note, dues_id)
audit_log (id, table_name, record_id, action, actor, ip_address, before_json, after_json)
members— one row per person.dues— coverage records ("this member's dues are paid through this period"). Adjacent periods for the same member are automatically merged into a single contiguous range.ledger— the append-only cash log (every credit and debit). A dues payment creates both aduesrow and a linkedledgerrow (ledger.dues_id); editing or deleting either side keeps the other in sync, so the two never silently drift apart.audit_log— one row per create/update/delete (and dues-period merge) acrossmembers,dues, andledger, with the before/after values as JSON. Readable viaGET /api/audit.
dues.member_id and ledger.dues_id are foreign keys with
ON DELETE CASCADE, and both are indexed, along with ledger.date and
audit_log(table_name, record_id).
dues.amount and ledger.amount are stored as integer pesewas
(1 cedi = 100 pesewas), not floating-point, so summed totals are always
exact. The HTTP API is unaffected — amounts are still sent and received
as decimal cedi values (e.g. 12.34); the conversion happens at the API
boundary in money.py.
All endpoints are under /api. Endpoints marked Auth require an
active admin session; those marked +CSRF additionally require a
matching X-CSRF-Token header (issued at login) since they change data.
/api/login, /api/logout, and /api/session are the only endpoints
reachable without a session, since they're how you get one.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/members |
✅ | List all members |
| POST | /api/members |
✅+CSRF | Add a member |
| PUT | /api/members/<id> |
✅+CSRF | Rename a member |
| DELETE | /api/members/<id> |
✅+CSRF | Remove a member (cascades dues/ledger) |
| GET | /api/dues |
✅ | List dues records (?member_id=) |
| POST | /api/dues |
✅+CSRF | Record a dues payment |
| PUT | /api/dues/<id> |
✅+CSRF | Edit a dues record |
| DELETE | /api/dues/<id> |
✅+CSRF | Delete a dues record |
| GET | /api/ledger |
✅ | List ledger transactions (?date_from=&date_to=&keyword=&type=) |
| GET | /api/ledger/summary |
✅ | Balance, totals, and counts |
| POST | /api/ledger |
✅+CSRF | Add a transaction |
| PUT | /api/ledger/<id> |
✅+CSRF | Edit a transaction |
| PUT | /api/ledger/<id>/link |
✅+CSRF | Link/unlink a credit to a dues record ({"dues_id": <id>|null}) |
| DELETE | /api/ledger/<id> |
✅+CSRF | Delete a transaction |
| POST | /api/login |
Authenticate, returns a CSRF token | |
| POST | /api/logout |
End the session | |
| GET | /api/session |
Check whether the current session is authenticated | |
| GET | /api/reports/monthly |
✅ | Monthly report (?month=YYYY-MM) |
| GET | /api/reports/comprehensive |
✅ | All-time report |
| GET | /api/audit |
✅ | Audit log (?table=members|dues|ledger&limit=) |
| GET | /api/admin/backups |
✅ | List existing backups |
| POST | /api/admin/backup |
✅+CSRF | Trigger a new backup |
pip install -r requirements-dev.txt
pytestThe suite (tests/) runs against a fresh temporary SQLite database per
test (never your real gmm_media.db) and covers auth/CSRF enforcement,
input validation, the dues↔ledger integrity behavior (linking, merging,
syncing, and cascading deletes), exact-money arithmetic, the audit log,
and backup/restore/migration behavior.
One-off data migrations live in migrations/ and are meant to be run
once, manually, in numeric order:
python migrations/002_link_ledger_to_dues.py
python migrations/003_money_to_minor_units.py001_data_corrections.py was a one-time historical fix and has already
been applied. 002_link_ledger_to_dues.py backfills the dues_id link
on ledger entries created before that column existed, where a confident
match can be found; it's safe to run more than once (it skips rows that
are already linked). 003_money_to_minor_units.py converts dues.amount
and ledger.amount from floating-point cedis to integer pesewas — back
up first (python backup.py); it's idempotent and refuses to touch a
database that's already been migrated.
python backup.py # create a timestamped, verified backup
python backup.py list # list existing backups
python backup.py restore <filename> [--yes]Backups are written to backups/ (gitignored) using SQLite's own backup
API and checked with PRAGMA integrity_check immediately after writing;
the oldest backups beyond the most recent 10 are pruned automatically.
Restoring first takes a safety backup of whatever is currently live, then
verifies and copies in the chosen backup.
Restore is CLI-only by design — it isn't exposed over HTTP, so a
network request alone can never overwrite your live data. Creating a
backup and listing existing ones are available to a logged-in admin via
POST /api/admin/backup and GET /api/admin/backups, for convenience
(e.g. wiring a "Backup now" button into the UI later).
- Change
SECRET_KEYandADMIN_PASSWORDbefore running this anywhere beyond your own machine — see Configuration. - The app is served over plain HTTP by default, intended for a trusted
local network. If you put it behind HTTPS, set
app.config['SESSION_COOKIE_SECURE'] = Trueinapp.py. - All state-changing requests require both a valid session and a CSRF token; passwords are compared with a timing-safe comparison.
- Every read and write requires a logged-in session — there is no anonymous access to financial or member data.
- Every create/update/delete is recorded in
audit_log(see Data Model), including the before/after values, so a bad edit or deletion is traceable and recoverable from history even though there's only one shared admin login. - User-supplied text is HTML-escaped before being rendered client-side.
app.run() uses Flask's built-in development server, which is
appropriate for this app's intended use (one admin, a handful of
viewers, on a local network) but isn't hardened for public internet
exposure. If you need to expose this beyond a LAN, put it behind a
production WSGI server (e.g. waitress or gunicorn) and a reverse
proxy with TLS.
MIT © 2026 Jason



