Self-hosted receipt, invoice, and document management for self-employed professionals.
Receiptory is a single-container web application that ingests receipts and invoices from multiple channels — web upload, email, Telegram, mobile camera, or a watched folder — and uses LLM-powered extraction to automatically classify, parse, and organize them. Built for running on a home NAS or any Docker host.
| Dashboard | Document Browser |
|---|---|
![]() |
![]() |
| Document Detail | Administration |
|---|---|
![]() |
![]() |
| Dark Mode |
|---|
![]() |
Ingestion
- Drag-and-drop web upload with SHA-256 deduplication
- Mobile document scanner with live boundary detection and perspective correction
- Telegram bot — forward photos or documents for automatic processing
- Gmail IMAP polling — auto-ingests receipts from labeled emails
- Watched folder — drop files on a network share for hands-free ingestion
Processing
- LLM-powered extraction via litellm (Gemini, OpenAI, Anthropic, and more)
- Single-pass OCR, field extraction, and classification
- Automatic vendor detection, amount parsing, tax ID matching
- Confidence scoring with human review queue for uncertain extractions
Management
- Document browser with full-text search (SQLite FTS5), filters, sorting, and pagination
- Document detail view with rendered page images and editable metadata
- Custom categories with LLM-guided classification (see sample categories)
- Export to ZIP with category-organized PDFs, CSV, and Excel metadata
Infrastructure
- Cloud backup to Google Drive and/or OneDrive via OAuth (or any rclone remote)
- Configurable retention policy (daily/weekly/monthly/quarterly)
- Dark theme with light/dark/system toggle
- Notification alerts via Telegram and email
- Single-user session auth
Receiptory ships with a default set of categories. You can customize them freely — category descriptions are fed into the LLM prompt to guide automatic classification.
| Category | Description |
|---|---|
| Office & Supplies | Office supplies, stationery, printer ink, small equipment |
| Subscriptions & Software | SaaS, software licenses, cloud services, streaming, gym, memberships |
| Hardware & Equipment | Computers, monitors, phones, peripherals, furniture |
| Travel | Flights, trains, taxis, public transport, tolls, parking |
| Car Fuel | Gasoline, diesel, EV charging |
| Car Maintenance | Vehicle repairs, servicing, tires, car wash, registration, insurance |
| Accommodation | Hotels, Airbnb, lodging for business trips |
| Meals & Entertainment | Business meals, client dinners, coffee meetings |
| Groceries | Supermarket, food, household consumables |
| Clothing | Clothes, shoes, accessories |
| Home & Garden | Home repairs, maintenance, appliances, garden supplies |
| Communication | Phone plans, internet service, SIM cards |
| Professional Services | Accountant, lawyer, consultant, freelancer fees, translation services |
| Insurance | Business insurance, professional liability, health insurance |
| Education & Training | Courses, conferences, books, certifications, professional development |
| Marketing & Advertising | Online ads, print ads, promotional materials, business cards, website costs |
| Rent & Workspace | Office rent, coworking space, home office expenses |
| Banking & Finance | Bank fees, payment processing fees, currency exchange, credit card fees |
| Taxes & Government | Tax payments, license fees, permits, government charges, municipal fees |
| Medical | Health-related expenses |
| Children & Family | Childcare, school fees, kids activities, baby supplies |
| Pets | Vet visits, pet food, grooming |
| Donations & Gifts | Charitable donations, gifts |
| Personal | Personal purchases not related to business activity |
| Utilities | Electricity, water, gas, waste disposal |
| Shipping & Delivery | Courier, postal services, package delivery, customs fees |
| Other | Expenses that don't fit any other category |
git clone https://github.com/LevMuchnik/Receiptory.git
cd Receiptory
cp .env.example .env
# Edit .env — at minimum set RECEIPTORY_LLM_API_KEY
docker compose up -d --buildOpen http://localhost:8484. Log in with admin / admin, then change the password in Administration > General.
-
Open the Unraid terminal (or SSH into your server)
-
Clone and configure:
mkdir -p /mnt/user/appdata/Receiptory cd /mnt/user/appdata/Receiptory git clone https://github.com/LevMuchnik/Receiptory.git . cp .env.example .env nano .env
-
Set at minimum:
RECEIPTORY_LLM_API_KEY=your-api-key-here RECEIPTORY_LLM_MODEL=gemini/gemini-3-flash-preview RECEIPTORY_AUTH_USERNAME=admin RECEIPTORY_AUTH_PASSWORD=your-password RECEIPTORY_PORT=8484
-
Build and start:
docker compose up -d --build
-
Open
http://your-nas-ip:8484
cd /mnt/user/appdata/Receiptory
git pull
docker compose up -d --buildAll data lives in data/ (mounted as a Docker volume) and survives container rebuilds:
| Path | Contents |
|---|---|
data/receiptory.db |
SQLite database |
data/storage/ |
Document files (originals, converted, filed) |
data/logs/ |
Application logs |
data/rclone.conf |
Cloud backup credentials (auto-generated; never in a backup) |
data/scanner_test_set/ |
Labelled scanner test frames, referenced by scanner_test_frames rows (backed up and restored) |
Most settings are configurable via the admin UI. Environment variables take precedence over database values.
| Variable | Default | Description |
|---|---|---|
RECEIPTORY_LLM_API_KEY |
— | API key for your LLM provider (required) |
RECEIPTORY_LLM_MODEL |
gemini/gemini-3-flash-preview |
litellm model string |
RECEIPTORY_AUTH_USERNAME |
admin |
Web UI username |
RECEIPTORY_AUTH_PASSWORD |
admin |
Web UI password |
RECEIPTORY_SECRET_KEY |
(random) | Session signing key — set for persistent sessions across restarts |
RECEIPTORY_PORT |
8484 |
HTTP port |
RECEIPTORY_DATA_DIR |
./data |
Data directory (DB, files, logs) |
RECEIPTORY_TELEGRAM_BOT_TOKEN |
— | Telegram bot token from @BotFather |
RECEIPTORY_GMAIL_ADDRESS |
— | Gmail address to poll via IMAP |
RECEIPTORY_GMAIL_APP_PASSWORD |
— | Gmail App Password (16 chars) |
RECEIPTORY_BACKUP_SCHEDULE |
0 2 * * * |
Backup cron schedule |
RECEIPTORY_THEME |
light |
Default theme: light, dark, or system |
RECEIPTORY_DEV |
0 |
Set to 1 when running with Vite dev server |
See .env.example for the full list with descriptions.
- Message @BotFather on Telegram, send
/newbot - Copy the bot token to
.envasRECEIPTORY_TELEGRAM_BOT_TOKEN(or set via Administration > Telegram) - Restart the container
- Optionally restrict access by adding your Telegram user ID (message @userinfobot to find it)
- Send or forward photos/documents to your bot
Uses IMAP with a Gmail App Password — no Google Cloud project needed.
- Enable 2-Step Verification: myaccount.google.com/security
- Generate an App Password: myaccount.google.com/apppasswords
- In Administration > Email, enter the Gmail address and App Password
- Click Test Connection to verify
Unread emails with PDF/image attachments are ingested automatically. HTML-only emails (e.g., digital receipts) are converted to PDF. Emails from unauthorized senders are flagged for review.
On Android devices, the app opens directly to a camera-based document scanner with:
- Live document boundary detection and perspective correction, with draggable corners to fix a bad crop
- What the review screen draws is what gets filed — when nothing is detected, the capture crops the box shown on screen instead of guessing at one
- Image enhancement (contrast, brightness optimization)
- Multi-page scanning with PDF assembly — each page is sized from its own pixels at 200 DPI, so a high-resolution capture reaches the LLM at full resolution
- Requires HTTPS or a Chrome flag for camera access on LAN
Camera app fallback. Where the in-app scanner cannot run — inside a WebView (Telegram, Slack) or over plain HTTP — a "Camera app" button hands the shot to your phone's own camera and uploads the photo straight to Receiptory. It needs no camera permission of its own, and it is also available from the scanner toolbar when the stock camera's autofocus or sensor resolution beats the in-app path.
Set RECEIPTORY_WATCHED_FOLDER_PATH to a directory. Files dropped there are auto-ingested and moved to a processed/ subfolder.
Receiptory backs up to Google Drive and/or OneDrive via OAuth. Both can be active simultaneously. Backups are scheduled via cron and include the database, all document files, the logs, the scanner Lab's labelled test frames, a metadata.jsonl export of every document, and a settings.json with sensitive values masked. (CSV and Excel are available from Export in the UI; they are not part of a backup.)
Deliberately excluded, to keep every copy to what actually has to survive:
| Excluded | Why |
|---|---|
storage/page_cache/ |
Page renders, rebuilt on demand. The largest thing in the tree by far. |
storage/tmp/ |
Ingestion scratch. Recreated by every download. |
storage/converted/*_converted.pdf |
Normalize scratch left beside the real <hash>.pdf, and only when that file is present and the same size. Conversions regenerate from the original, which is always in the backup. |
data/rclone.conf |
The credentials for the remote the backup is uploaded to. The archive travels unencrypted to that same service, so shipping the keys inside it would defeat the point. |
The database is captured with SQLite's online backup API, so it is a consistent snapshot rather than a file copy, and it needs no -wal/-shm sidecar to restore.
Secrets are stripped from the backup. The uploaded copy has the LLM API keys, Telegram bot token, Gmail app password, cloud OAuth tokens and login password hash removed, because the backup is uploaded with no encryption of its own. Restoring therefore requires re-entering them — settings.json lists which keys existed, with masked values.
Every backup is verified before it is reported as done: the snapshot must open, carry the expected schema, every original its document rows reference must be present and hash to its own name, and every filed copy must be present. A backup that would not restore fails the run instead of being uploaded.
Run this on the host, from a checkout of the repo with uv sync --all-extras done — scripts/ is not baked into the Docker image. Stop the container first.
# Is this backup any good? Reads only, writes nothing.
uv run python scripts/restore_backup.py --verify-only /path/to/backup
# Rebuild into a fresh directory, then swap it in (reversible).
uv run python scripts/restore_backup.py /path/to/backup data.restored
mv data data.old && mv data.restored data
# Or overwrite an existing data directory in place.
uv run python scripts/restore_backup.py /path/to/backup data --forceThe script verifies the backup, assembles the restore in a sibling directory, verifies that too, and only then swaps it into place with two renames. The target is never a mixture of two installs, and the directory it replaces is kept as <target>.pre-restore-<timestamp> rather than deleted, so the whole operation is undone by a mv.
Each item states where it comes from, and the script prints which source it used:
| Item | Source |
|---|---|
database, storage/, logs/ |
the backup |
scanner_test_set/ |
the backup, falling back to the install being replaced — backups made before frames were included have no copy, and the scanner_test_frames rows would otherwise restore pointing at nothing |
rclone.conf |
the install being replaced, since it is never in a backup |
The restored system has no password until you set one. auth_password_hash is stripped from the backup, so on first start it accepts the default admin / admin. The script says so loudly. Set a real password immediately, or pin RECEIPTORY_AUTH_PASSWORD in .env before starting it.
Restore to a sibling path if your data directory is a mount point — a mount cannot be renamed, and the script refuses up front rather than failing after the copy.
rclone.conf is not in a backup, so it is carried over from the install being replaced whenever there is one. A restore onto fresh hardware, or into a fresh directory, has no copy to carry: the Google Drive and OneDrive remotes rebuild themselves from the stored tokens once you reconnect them in Administration > Resilience, but a remote you added by hand (sftp, S3, a local path) exists only in that file and then has to be reconfigured from scratch. Anything pinned in .env keeps working from .env.
- Go to Google Cloud Console > Credentials
- Create a project if needed, then + Create Credentials > OAuth client ID
- Configure consent screen (External), select Web application
- Add redirect URI:
http://your-nas-ip:8484/api/cloud-auth/callback/gdrive - Copy Client ID and Secret
- Enable the Google Drive API
- In Receiptory: Administration > Resilience > paste credentials > Connect Google Drive
- Go to Azure Portal > App registrations
- + New registration — select "Accounts in any organizational directory and personal Microsoft accounts"
- Add redirect URI (Web):
http://your-nas-ip:8484/api/cloud-auth/callback/onedrive - Copy Application (client) ID
- Under Certificates & secrets, create a new secret and copy the Value
- Under API permissions, add:
Files.ReadWrite.All,User.Read,offline_access - In Receiptory: Administration > Resilience > paste credentials > Connect OneDrive
| Type | Schedule | Default Retention |
|---|---|---|
| Daily | Every day | 7 days |
| Weekly | Sundays | 4 weeks |
| Monthly | 1st of month | 3 months |
| Quarterly | Jan/Apr/Jul/Oct 1st | Never deleted |
Configurable in Administration > Resilience > Backup Schedule.
- Python 3.12+, uv
- Node.js 20.19+ to build (the Docker frontend build stage uses Node 24; Node 20 reached end-of-life 2026-04-30, so 22+ is recommended)
uv sync --all-extras
RECEIPTORY_DEV=1 uv run uvicorn backend.main:create_app --factory --reload --port 8484cd frontend && npm install && npm run devThe Vite dev server runs on port 5173 with API proxy to localhost:8484. Set RECEIPTORY_DEV=1 to prevent the backend from serving static files.
# Backend (pytest)
uv run pytest tests/ -v
# Frontend (vitest — pure-logic suite, node environment, no jsdom)
cd frontend && npm testBoth suites run on a fresh clone with nothing extra to fetch: the sample PDF the backend tests use is generated at test time, not read from a fixture directory.
The frontend build tooling (tailwindcss, @tailwindcss/vite, tw-animate-css, the shadcn generator) lives in devDependencies, so npm install / npm ci is required before npm run build. --omit=dev will not build the frontend.
A sidecar container for in-place development on UNRAID (no host installs needed).
Run these from the repo directory on the NAS (the docker-compose.claude.yml path is relative):
cd /mnt/user/appdata/Receiptory
# First run / after Dockerfile changes — rebuild then launch:
docker compose -f docker-compose.claude.yml up -d --build && docker exec -it claude-dev claude-yolo
# Normal launch (container already running):
docker exec -it claude-dev claude-yoloclaude-yolo is a script baked into the image that runs:
claude --dangerously-skip-permissions --continue
--continue resumes the most recent conversation automatically. Drop it for a fresh session:
docker exec -it claude-dev claude --dangerously-skip-permissionsAuth and Claude config persist in .claude-dev-home/.
cd frontend && npm run build && cd ..
uv run uvicorn backend.main:create_app --factory --host 0.0.0.0 --port 8484receiptory/
├── backend/
│ ├── main.py # FastAPI app factory, lifespan
│ ├── config.py # Settings (env > db > defaults)
│ ├── auth.py # Session-based authentication
│ ├── database.py # SQLite WAL + migration runner
│ ├── storage.py # File I/O, page rendering
│ ├── models.py # Pydantic request/response models
│ ├── api/ # REST endpoint routers
│ ├── processing/ # LLM extraction pipeline, queue
│ ├── ingestion/ # Telegram bot, Gmail poller, watched folder
│ ├── backup/ # Scheduler, runner, rclone, OAuth
│ └── notifications/ # Telegram + email notification dispatch
├── frontend/src/
│ ├── pages/ # Page components
│ ├── components/ # Reusable UI components
│ ├── components/scanner/ # Mobile document scanner
│ ├── contexts/ # Auth + Theme providers
│ ├── lib/ # API client, hooks, utilities, PDF builder
│ └── lib/scanner/ # Boundary detection, geometry, canvas helpers
├── migrations/ # Numbered SQL migration files
├── scripts/ # Dev utilities (JSON-mode A/B harness)
├── tests/ # pytest test suite
├── Dockerfile # Multi-stage build
└── docker-compose.yml
| Layer | Technology |
|---|---|
| Backend | Python 3.12, FastAPI, SQLite (WAL + FTS5), litellm |
| Frontend | React 19, TypeScript, Vite, Tailwind CSS v4, shadcn/ui |
| Document processing | PyMuPDF, Pillow, WeasyPrint |
| Mobile scanner | Scanic (WASM), jsPDF |
| Cloud backup | rclone (Google Drive, OneDrive, S3, etc.) |
| Deployment | Docker Compose, single container |
This project is licensed under the GNU Affero General Public License v3.0.




