Learns to split the receipt automatically
A Kotlin Multiplatform app (Compose Multiplatform) targeting Android, iOS, and the web (Wasm). See product-vision.md and project-structure.md for background.
See CONTRIBUTING.md for running the CI checks locally and the branch protection settings.
composeApp/- shared Compose UI and logic (Android library, iOS framework, Wasm web app)androidApp/- Android application entry pointiosApp/- Xcode project for the iOS app
- JDK 17 or newer
- Android Studio (latest stable) with the Android SDK installed - compile SDK 37, min SDK 24
- For iOS: a Mac with Xcode, plus an Apple Developer account to run on a physical device
- No separate Gradle install is needed; use the included Gradle wrapper (
./gradlew, orgradlew.baton Windows)
git clone https://github.com/Monster0506/Divvy.git
cd DivvyOpen the project root in Android Studio and let Gradle sync, or verify the setup from the command line:
./gradlew buildFrom Android Studio, select the androidApp run configuration and choose an emulator or connected device.
From the command line, with a device or emulator connected:
./gradlew :androidApp:installDebugDATABASE_URL=jdbc:postgresql://localhost:5432/divvy DATABASE_USER=divvy DATABASE_PASSWORD=divvy ML_SERVICE_URL=http://localhost:8000 API_KEY=dev-key ./gradlew :server:runThe Ktor server listens on port 8080 by default. Configuration comes from environment variables:
| Variable | Required | Description |
|---|---|---|
PORT |
no | Listening port (default 8080) |
DATABASE_URL |
yes | JDBC URL of the Postgres database |
DATABASE_USER |
yes | Database user |
DATABASE_PASSWORD |
yes | Database password |
ML_SERVICE_URL |
yes | Base URL of the ML service |
API_KEY |
yes | Shared API key, sent by clients in the X-Api-Key header |
APP_VERSION |
no | Version reported by /health (default 1.0) |
The server refuses to start if a required variable is missing. GET /health returns {"status":"ok","version":"..."} and is public. Every other route requires the X-Api-Key header to match API_KEY, otherwise the server returns 401. GET /auth/check returns 204 when the key is accepted and 401 when it isn't; the app calls /health and then /auth/check on launch and whenever the server settings change, and shows one banner if the server is unreachable or rejects the key. Locally, API_KEY=dev-key works with curl -H "X-Api-Key: dev-key" http://localhost:8080/....
Receipt images are stored behind the ImageStorage interface (put, get, delete). LocalFileImageStorage writes files into a configured directory (a Docker volume in deployment) under generated UUID keys. Keys are validated as canonical UUIDs, so client filenames and path traversal never reach the filesystem.
Run the server tests with ./gradlew :server:test.
./gradlew :composeApp:wasmJsBrowserDevelopmentRunThis starts a dev server and opens the app in your browser.
To rebuild and reload the browser automatically whenever you save a file, add --continuous:
./gradlew :composeApp:wasmJsBrowserDevelopmentRun --continuousThe dev server uses port 8080, the same default as the Ktor server, so set PORT to something else (e.g. PORT=8081) when running the server alongside it.
- Set your Apple Developer team ID in
iosApp/Configuration/Config.xcconfig:TEAM_ID=YOUR_TEAM_ID - Open
iosApp/iosApp.xcodeprojin Xcode. - Select a simulator or connected iPhone and click Run.
See JetBrains' guide on running on a real iOS device for device setup details.
From Receipts, tap Scan receipt, then take a photo or choose one from Gallery. Review the prepared image and tap Upload photo. The app shows upload progress, keeps the image for retry after a failure, and displays the new receipt ID in processing state when the server saves it. Images are converted to a legible JPEG before upload. After the upload the app waits while the server reads the photo, then opens the review screen on the real receipt: fix or add lines, choose who owns each item, accept or change Divvy's guesses, and confirm. The Receipts tab lists the household's receipts from the server, a confirmed receipt shows each person's share, and it can be reopened for correction. Account holds the household's auto-assign switch and confidence threshold.
Start the Ktor server before uploading. The Android emulator uses http://10.0.2.2:8080 by default; the iOS simulator uses http://localhost:8080. On a physical device, open Account > Server, enter a reachable server URL and its API key, then save. Use a deployed HTTPS URL, or for local development use http://<your-computer-LAN-IP>:8080 while the phone and computer are on the same network. localhost on a phone points to the phone itself. iOS will request local-network access for a LAN server. You can also set build defaults with -Pdivvy.apiBaseUrl=... and -Pdivvy.apiKey=...; the in-app settings override them.
Put the server address and key in the git-ignored local.properties at the repository root, next to sdk.dir:
divvy.apiBaseUrl=https://your-server.example
divvy.apiKey=the-api-key
local.properties wins over gradle.properties and -P, so every build installed from your machine, for example ./gradlew :androidApp:installDebug, starts pointed at that server with no setup in the app. Never put the key in a tracked file. Gradle regenerates the setting when it changes, so no clean build is needed. The values are only defaults: once someone has saved a server in Account > Server on a device, that saved value is used instead, so clear the app data (or save the new server in Account) after switching. The app creates a server household on the first upload when onboarding has only stored a local placeholder ID.
The server must be built from a revision containing POST /households/{id}/receipts (this PR). If an older Docker server responds 404 Not Found with an empty body, rebuild it from the updated source with docker compose up -d --build server. A current server responds to a nonexistent household with JSON like {"code":"not_found","message":"household not found"}. GET /health can confirm reachability, but does not verify the upload route. A proxy-generated 503 Service Unavailable means the upstream service needs to be started or repaired before the app can upload. Do not share API or Qwen keys in issue comments.
The Python ML service lives in ml/. See ml/README.md for setup, run, test and lint commands.
The backend runs as a Docker Compose stack on a single host (target: 4 vCPU / 8 GB / Linux). Only server is published to the host, on port 8080; ml and db live on an internal Docker network with no published ports.
| Service | Image | Memory limit | CPU limit | Data |
|---|---|---|---|---|
db |
pgvector/pgvector:pg16 |
1.5 GB | 1.5 | pgdata volume |
ml |
built from ml/ |
3 GB | 2.0 | none |
server |
built from server/Dockerfile |
1.5 GB | 1.5 | receipts volume |
The receipts volume is mounted at /data/receipts for uploaded receipt images.
- Docker Engine with the Compose v2 plugin (
docker compose version) - Git
git clone https://github.com/Monster0506/Divvy.git
cd Divvy
cp .env.example .envEdit .env and set real values. Generate secrets with openssl rand -hex 24. Then:
docker compose up -d --build
docker compose psThe first build takes several minutes. All three services should reach healthy. Verify with curl http://<server-ip>:8080/health.
The server speaks plain HTTP. The X-Api-Key header travels unencrypted, so don't use this setup for real user data without TLS.
api.example.com {
reverse_proxy 127.0.0.1:8080
}
git pull
docker compose up -d --buildNamed volumes survive this. Never run docker compose down -v, which deletes the database and uploaded images.
Back up both volumes. Run these from the repo root.
Database dump:
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > divvy-$(date +%F).dumpDatabase restore (replaces existing data):
docker compose exec -T db sh -c 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists' < divvy-2026-01-01.dumpReceipt images:
docker run --rm -v divvy_receipts:/data -v "$PWD":/backup alpine tar czf /backup/receipts-$(date +%F).tgz -C /data .To restore the images, extract the archive into the volume the same way:
docker run --rm -v divvy_receipts:/data -v "$PWD":/backup alpine tar xzf /backup/receipts-2026-01-01.tgz -C /dataCopy backups off the server. A backup that lives only on the same disk is not a backup.
mlhas no outbound internet access (internal network). Model files must be baked into its image.- Changing
POSTGRES_PASSWORDin.envdoes not change an existing database. Alter the role inside Postgres instead.
GitHub Actions runs each workflow only when its files change, so a docs-only change runs no build jobs:
| Workflow | Runs when | Does | Typical time |
|---|---|---|---|
Gradle (.github/workflows/gradle.yml) |
PRs and pushes to main touching composeApp/, androidApp/, server/, gradle/ or the build scripts |
./gradlew :composeApp:check :androidApp:assembleDebug :server:test on JDK 17; uploads test reports on failure |
~4-5 min |
ML service (.github/workflows/ml.yml) |
PRs and pushes to main touching ml/ |
ruff, pytest and a Docker build with a /health check. See ml/README.md. |
~20 s per job (parallel) |
iOS (.github/workflows/ios.yml) |
Nightly at 06:17 UTC, manual trigger, and PRs touching composeApp/src/iosMain/ or iosApp/ |
:composeApp:linkDebugFrameworkIosSimulatorArm64 on macOS |
IOS_TIME |
To run the iOS build by hand, use Actions > iOS > Run workflow, or run gh workflow run ios.yml.