{"id":51595370,"url":"https://github.com/monkey-mode/woofjot","last_synced_at":"2026-07-11T18:30:27.181Z","repository":{"id":349820964,"uuid":"1203937563","full_name":"monkey-mode/woofjot","owner":"monkey-mode","description":"Scan Thai bank transfer slips and automatically extract amount, date, time, category, sender, and receiver using Claude Vision. Everything runs locally with a single Docker Compose command.","archived":false,"fork":false,"pushed_at":"2026-04-16T06:55:46.000Z","size":360,"stargazers_count":0,"open_issues_count":2,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-16T08:37:03.320Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/monkey-mode.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-07T14:29:58.000Z","updated_at":"2026-04-16T06:55:50.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/monkey-mode/woofjot","commit_stats":null,"previous_names":["monkey-mode/woofjot"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/monkey-mode/woofjot","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/monkey-mode%2Fwoofjot","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/monkey-mode%2Fwoofjot/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/monkey-mode%2Fwoofjot/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/monkey-mode%2Fwoofjot/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/monkey-mode","download_url":"https://codeload.github.com/monkey-mode/woofjot/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/monkey-mode%2Fwoofjot/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35372639,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-11T02:00:05.354Z","response_time":104,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-07-11T18:30:26.672Z","updated_at":"2026-07-11T18:30:27.159Z","avatar_url":"https://github.com/monkey-mode.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# หมาจด / WoofJot\n\nบันทึกค่าใช้จ่ายจากสลิปธนาคารไทย ด้วย Claude Vision AI\n\nScan Thai bank transfer slips and automatically extract amount, date, time, category, sender, and receiver using Claude Vision. Everything runs locally with a single Docker Compose command.\n\n## Features\n\n- Upload up to 10 Thai bank slips at once (KBank, SCB, KTB, BBL, TTB, PromptPay)\n- Claude Vision extracts amount, date, time, category, sender, and receiver automatically\n- Non-slip images (photos, screenshots, etc.) are detected and rejected with a clear error\n- Each upload produces 3 images: storage copy (2400px), LLM copy (1200px for Claude), thumbnail (400px for fast UI)\n- Manual income/expense entry without a slip — add cash payments, transfers, or any expense directly\n- Tap any expense to expand — view slip details or manual entry placeholder, edit data, or delete\n- Tap the slip thumbnail to view the full image inline\n- Monthly view with category donut chart and per-category breakdown (aggregated in SQL)\n- Navigate between months with the month selector\n- Sort expenses by slip date or upload date — preference persisted across sessions\n- Skeleton loading states for header totals, category summary, and expense list\n- Switch between Thai and English — language preference persisted across sessions\n\n## Stack\n\n| Layer | Technology |\n|---|---|\n| Frontend | Next.js 14 (App Router), Tailwind CSS |\n| slip-api | FastAPI (Python 3.11) — presign, webhook, scan poll |\n| expenses-api | FastAPI (Python 3.11) — expense CRUD |\n| resize-worker | Python async — Pillow resize → MinIO → Redis |\n| slip-worker | Python async — Claude Vision extraction → Postgres |\n| AI | Anthropic Claude Haiku (`claude-haiku-4-5-20251001`) |\n| Blob storage | MinIO (S3-compatible) |\n| Event bus | Redis pub/sub |\n| Database | PostgreSQL 16 |\n| Runtime | Docker Compose |\n\nNo external services — everything runs locally with one command.\n\n## How it works\n\n```mermaid\nsequenceDiagram\n    actor Frontend\n    participant SlipAPI as slip-api\n    participant ResizeWorker as resize-worker\n    participant SlipWorker as slip-worker\n    participant MinIO\n    participant Redis\n    participant Postgres\n    participant Claude as Claude Haiku API\n\n    Note over Frontend,MinIO: STEP 1 — Presign \u0026 Upload\n    Frontend-\u003e\u003e+SlipAPI: POST /upload/presign\n    SlipAPI-\u003e\u003ePostgres: INSERT images (status=pending)\n    SlipAPI-\u003e\u003eMinIO: Generate presigned PUT URL\n    SlipAPI--\u003e\u003e-Frontend: { upload_url, job_id }\n    Frontend-\u003e\u003eMinIO: PUT original image bytes\n    MinIO--\u003e\u003eFrontend: 200 OK\n\n    Note over MinIO,Redis: STEP 2 — Webhook → Publish resize event\n    MinIO-\u003e\u003e+SlipAPI: POST /webhook/minio (S3 event)\n    SlipAPI-\u003e\u003ePostgres: UPDATE images SET status=uploaded\n    SlipAPI-\u003e\u003eRedis: PUBLISH resize:{job_id}\n    SlipAPI--\u003e\u003e-MinIO: 200 OK\n\n    Note over Redis,MinIO: STEP 3 — Resize Worker (3 images)\n    Redis--\u003e\u003eResizeWorker: pmessage resize:*\n    ResizeWorker-\u003e\u003ePostgres: UPDATE images SET status=resizing\n    ResizeWorker-\u003e\u003eMinIO: Download original image\n    ResizeWorker-\u003e\u003eResizeWorker: Pillow → 2400px JPEG (q=88) — storage copy\n    ResizeWorker-\u003e\u003eMinIO: Upload {job_id}_store.jpg\n    ResizeWorker-\u003e\u003eResizeWorker: Pillow → 1200px JPEG (q=85) — LLM copy\n    ResizeWorker-\u003e\u003eMinIO: Upload {job_id}_opt.jpg\n    ResizeWorker-\u003e\u003eResizeWorker: Pillow → 400px JPEG (q=75) — thumbnail\n    ResizeWorker-\u003e\u003eMinIO: Upload {job_id}_thumb.jpg\n    ResizeWorker-\u003e\u003ePostgres: UPDATE images SET url=store_url, thumbnail_url=thumb_url\n    ResizeWorker-\u003e\u003eRedis: PUBLISH scan:{job_id}\n\n    Note over Redis,Postgres: STEP 4 — Slip Worker Extracts\n    Redis--\u003e\u003eSlipWorker: pmessage scan:*\n    SlipWorker-\u003e\u003ePostgres: UPDATE images SET status=processing\n    SlipWorker-\u003e\u003eMinIO: Download {job_id}_opt.jpg\n    SlipWorker-\u003e\u003e+Claude: Optimized image + extraction prompt\n    Claude--\u003e\u003e-SlipWorker: { amount, currency, date, time, category, from, to }\n    SlipWorker-\u003e\u003ePostgres: INSERT expenses (image_id FK)\n    SlipWorker-\u003e\u003ePostgres: UPDATE images SET status=done\n\n    Note over Frontend,SlipAPI: STEP 5 — Poll for Result\n    Frontend-\u003e\u003e+SlipAPI: GET /scan/{job_id}\n    SlipAPI-\u003e\u003ePostgres: SELECT status, expense FROM images LEFT JOIN expenses\n    SlipAPI--\u003e\u003e-Frontend: { status: \"done\", result: {...} }\n```\n\nThe frontend uploads the original image directly to MinIO via a presigned URL. MinIO fires a webhook to slip-api, which publishes a resize event to Redis. The resize-worker downloads the original and produces three images: a high-quality storage copy (`_store.jpg`, 2400px), an LLM-optimized copy (`_opt.jpg`, 1200px), and a thumbnail (`_thumb.jpg`, 400px). It updates `images.url` and `images.thumbnail_url` in Postgres, then publishes to the scan channel. The slip-worker downloads the optimized copy, calls Claude Haiku with the extraction prompt, and writes the result to Postgres. The frontend polls until done and displays the thumbnail in the expense list.\n\n## Quick start\n\n**Prerequisites:** Docker, Docker Compose, an [Anthropic API key](https://console.anthropic.com/)\n\n```bash\ngit clone https://github.com/your-username/woofjot.git\ncd woofjot\n\ncp .env.example .env\n# Edit .env — set ANTHROPIC_API_KEY (everything else has working defaults)\n\ndocker compose up --build\n```\n\n| Service | URL |\n|---|---|\n| App | http://localhost:3000 |\n| slip-api docs | http://localhost:8000/docs |\n| expenses-api docs | http://localhost:8001/docs |\n| MinIO console | http://localhost:9001 (minioadmin / minioadmin) |\n\n## Environment variables\n\nKey variables in `.env` (see `.env.example` for full list):\n\n| Variable | Description |\n|---|---|\n| `ANTHROPIC_API_KEY` | Required. Your Anthropic API key. |\n| `CLAUDE_MODEL` | Claude model ID. Default: `claude-haiku-4-5-20251001` |\n| `RESIZE_MAX_PX` | LLM-optimised copy max dimension. Default: `1200` |\n| `RESIZE_QUALITY` | LLM-optimised copy JPEG quality. Default: `85` |\n| `RESIZE_STORE_MAX_PX` | Storage copy max dimension. Default: `2400` |\n| `RESIZE_STORE_QUALITY` | Storage copy JPEG quality. Default: `88` |\n| `RESIZE_THUMB_MAX_PX` | Thumbnail max dimension. Default: `400` |\n| `RESIZE_THUMB_QUALITY` | Thumbnail JPEG quality. Default: `75` |\n| `MINIO_EXTERNAL_ENDPOINT` | MinIO endpoint reachable by the browser. Default: `localhost:9000` |\n\n## API\n\n**slip-api** `localhost:8000`\n\n| Method | Path | Description |\n|---|---|---|\n| `POST` | `/upload/presign` | Get presigned PUT URL + job_id |\n| `POST` | `/webhook/minio` | MinIO S3 event receiver |\n| `GET` | `/scan/{job_id}` | Poll scan status and result |\n| `GET` | `/health` | Health check |\n\n**expenses-api** `localhost:8001`\n\n| Method | Path | Description |\n|---|---|---|\n| `GET` | `/expenses?month=YYYY-MM\u0026sort=date\\|uploaded` | List expenses + category summary for a month |\n| `POST` | `/expenses` | Create a manual expense (no slip required) |\n| `PATCH` | `/expenses/{id}` | Update any extracted field or category/note |\n| `DELETE` | `/expenses/{id}` | Delete expense (and slip image if attached) |\n| `GET` | `/health` | Health check |\n\n`GET /expenses` returns `{ expenses, summary }`. `sort=date` (default) filters and orders by slip date; `sort=uploaded` by upload timestamp. All filtering and category aggregation happen in SQL — the frontend renders the response directly without further processing.\n\n### PATCH /expenses/{id} body\n\nAll fields are optional. Only provided fields are updated.\n\n```json\n{\n  \"amount\": 466.00,\n  \"date\": \"2026-04-07\",\n  \"time\": \"22:24:00\",\n  \"sender\": \"Suphachai P.\",\n  \"receiver\": \"Coffee Shop Co., Ltd.\",\n  \"category\": \"food\",\n  \"note\": \"lunch\"\n}\n```\n\n## Expense categories\n\n| Key | ภาษาไทย |\n|---|---|\n| `food` | อาหาร |\n| `transport` | เดินทาง |\n| `shopping` | ช้อปปิ้ง |\n| `utilities` | ค่าน้ำค่าไฟ |\n| `health` | สุขภาพ |\n| `entertainment` | บันเทิง |\n| `invest` | ลงทุน |\n| `other` | อื่นๆ |\n\nCategory is inferred by Claude from the merchant name or description on the slip. Users can correct it in the app.\n\n## Supported banks\n\nKBank (กสิกรไทย) · SCB (ไทยพาณิชย์) · KTB (กรุงไทย) · BBL (กรุงเทพ) · TTB (ทหารไทยธนชาต) · PromptPay QR\n\nDates in Buddhist Era (e.g. พ.ศ. 2568) are automatically converted to CE.\n\n## Project structure\n\n```\n├── frontend/\n│   ├── app/\n│   │   ├── page.tsx               # Main page — month nav, totals, expense list\n│   │   └── layout.tsx\n│   ├── components/\n│   │   ├── SlipUploader.tsx       # File picker → presign → PUT → poll (up to 10)\n│   │   ├── ScanStatus.tsx         # Polling progress indicator\n│   │   ├── ExpenseList.tsx        # Grouped list with tap-to-expand rows\n│   │   ├── ManualEntryForm.tsx    # Manual income/expense entry form\n│   │   ├── MonthlySummary.tsx     # Donut chart + category breakdown\n│   │   └── DonutChart.tsx         # Pure SVG donut (no chart library)\n│   └── lib/\n│       ├── api.ts                 # All fetch calls to both backends\n│       ├── types.ts               # Shared TypeScript interfaces\n│       ├── i18n.tsx               # I18nProvider, useI18n hook\n│       └── locales/\n│           ├── th.ts              # Thai strings\n│           └── en.ts              # English strings\n├── backend/\n│   ├── sync/\n│   │   ├── slip/                  # slip-api  (port 8000)\n│   │   │   ├── routers/\n│   │   │   │   ├── upload.py      # POST /upload/presign\n│   │   │   │   ├── webhook.py     # POST /webhook/minio\n│   │   │   │   └── scan.py        # GET /scan/{job_id}\n│   │   │   ├── db.py\n│   │   │   ├── storage.py\n│   │   │   ├── pubsub.py\n│   │   │   └── main.py\n│   │   └── expenses/              # expenses-api  (port 8001)\n│   │       ├── db.py\n│   │       ├── models.py\n│   │       └── main.py\n│   └── async/\n│       ├── resize/                # resize-worker\n│       │   ├── resizer.py         # Pillow resize logic\n│       │   ├── storage.py\n│       │   ├── pubsub.py\n│       │   └── worker.py\n│       └── slip/                  # slip-worker\n│           ├── claude.py          # Claude Haiku extraction prompt + logic\n│           ├── storage.py\n│           ├── pubsub.py\n│           └── worker.py\n├── docker-compose.yml\n├── .env.example\n└── CLAUDE.md                      # Architecture reference for AI assistants\n```\n\n## Design decisions\n\n- **No shared files across services.** Each service owns its own `db.py`, `config.py`, `storage.py` scoped to only what it needs.\n- **Redis is pub/sub only.** No `GET`/`SET` — all state lives in Postgres.\n- **Single source of truth.** `images.status` drives the entire scan lifecycle.\n- **Three images per upload.** `_store.jpg` (2400px) for high-quality display, `_opt.jpg` (1200px) for Claude to reduce token cost, `_thumb.jpg` (400px) for fast thumbnail rendering in the expense list.\n- **`images.url` updated after resize.** Initially points to the raw upload; resize-worker overwrites it with the storage copy URL so the UI always shows the compressed-but-high-quality version.\n- **Non-slip detection in the prompt.** Claude is instructed to return `{\"not_a_slip\": true}` when the image is not a bank transfer slip. The worker detects this sentinel and stores a Thai-language error message (`ไม่พบสลิปธนาคารในภาพนี้`) rather than a raw exception string.\n- **Month filtering, sort, and category aggregation in SQL.** `GET /expenses` returns `{ expenses, summary }` in one query. The frontend never re-aggregates — it renders `summary` as-is.\n- **Manual entries coexist with slip-backed entries.** `expenses.image_id` is nullable. `GET /expenses` uses `LEFT JOIN images` so both types appear. `DELETE` checks `image_id` first — if set it cascades via the image row; if null it deletes the expense row directly.\n- **Zero-dependency i18n.** All UI strings live in `lib/locales/th.ts` and `lib/locales/en.ts`. A React Context (`useI18n`) serves the active locale; `localStorage` persists the preference. No i18n library needed.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmonkey-mode%2Fwoofjot","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmonkey-mode%2Fwoofjot","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmonkey-mode%2Fwoofjot/lists"}