June 1, 2026
Locdoc: tenant screening for Swiss real estate agencies
Renting an apartment in Switzerland is a paper exercise. Agencies receive stacks of dossiers in inconsistent formats, manually read pay slips, chase missing documents, and make decisions with incomplete data. Locdoc handles the extraction, scoring, and selection workflow so agencies can focus on the decision itself.

The approach
I shipped an MVP fast. Not polished, but real: a working upload flow, Claude extraction, a scored result. Then I talked to agencies.
That step matters. It is easy to overbuild a feature nobody needs or to optimize a score that no agency actually looks at. Talking to the market first meant I knew what mattered before spending time on it.
The first paying client came before the product was complete. That forced useful prioritization: what does a real agency need to start using this today. Since then the product has iterated on real feedback. Billing, notification emails, waitlist management, customizable email templates. Each piece came from a real conversation, not a guess.
How it works
An agency creates a listing and gets a public upload link. Candidates open the link and submit their documents. No account required on their side. The backend processes each file with Claude vision, extracts structured data, scores the dossier, and makes it available in the agency dashboard.
The agency sees a list of scored candidates for each listing. They can sort by score, read the full extracted data, and move candidates through a selection workflow: retenu, en_attente (with a waitlist rank), refuse, or desiste. When they make a decision, they send a notification email from inside the app, with a pre-filled template they can edit before sending.
The desiste status is irreversible, enforced by the backend with a 409 on any further status change attempt.
if candidate.status == "desiste":
raise HTTPException(status_code=409)Architecture
Three rules enforced across the codebase. Routes validate input, call a service, return: no business logic. All reads and writes go through repositories, where ownership checks live. Processing modules are fully isolated: no database access, no FastAPI imports. They take bytes in, return structured data out.
AI extraction pipeline
file bytes + mime_type
→ extractor.extract_data() # Claude vision, one call per file
→ scorer.score() # pure functionEach uploaded file goes through Claude's vision API. The extractor pulls structured fields: name, monthly income, contract type, employer, debt collection history. The result feeds the scorer. The whole pipeline runs as a FastAPI BackgroundTask so the upload response is immediate.
background_tasks.add_task(process_dossier, dossier_id)
return {"id": dossier_id}Dossier status machine: pending → processing → ready → done, or error if extraction fails.
Scoring rules:
| Condition | Effect |
|---|---|
| Rent-to-income ratio below 3.0 | Score −40, flagged |
| Active debt collection | Score −50, flagged |
| Fixed-term or freelance contract | Score −10, flagged |
| Missing critical fields | Score capped at 40 |
The agency sees a numeric score and a list of flags. They can still read the full extracted dossier, but the score gives them a starting point when they have 30 applications.
Frontend
TanStack Start with SSR, file-based TanStack Router, TanStack Query, Tailwind v4, react-hook-form, Zod.
Domain API functions live in src/api/<domain>.ts: no React, no hooks. React Query hooks wrap them in src/hooks/use-<domain>.ts. Query keys are centralized and never inlined. The session query has staleTime: Infinity and is populated by SSR on page load. After login, the code removes the session from the cache and navigates: the subsequent beforeLoad fetches the fresh session, so the login response never sets the cache directly.
queryClient.removeQueries({ queryKey: sessionKeys.current() })
navigate({ to: "/dashboard" })The backend exposes an OpenAPI schema. generate-api-types regenerates openapi.gen.ts from the running backend. CI fails if the committed file differs. Frontend types and backend schemas cannot drift silently.
Auth
Email/password: register, verify email (single-use token, 24h TTL, SHA-256 hashed in DB), login. Google OAuth is also supported: the backend verifies the Google ID token, creates or links the account, and sets the cookie.
JWT HS256, 60-minute TTL, httpOnly cookie. In production, frontend and backend are on different subdomains of locdoc.ch. COOKIE_DOMAIN=locdoc.ch must be set or every page refresh and every Stripe redirect logs the user out. SameSite=Lax allows top-level cross-site navigations like Stripe redirects without weakening cross-origin protection.
Stripe billing
Two plans: free (one listing) and pro (unlimited). The require_pro FastAPI dependency gates any route that needs the paid plan.
async def create_listing(agency = Depends(require_pro)):The webhook handles the full subscription lifecycle: successful payment, updates, cancellations, failed invoices. past_due subscriptions count as active alongside active and trialing. A temporarily failed payment does not immediately lock an agency out.
ACTIVE_STATUSES = {"active", "trialing", "past_due"}CI/CD
PR pipeline: path-based change detection. If only frontend/** changed, the backend job is skipped and vice versa. Backend job: ruff format and lint, pytest against SQLite in-memory. Frontend job: TypeScript check, lint, Vitest, full build. A dedicated contracts job regenerates openapi.gen.ts and fails with git diff --exit-code if the committed file differs.
Release pipeline: triggered by v*.*.* tags.
- Full CI run.
- Backend and frontend Docker images built in parallel and pushed to GHCR. Frontend env vars are baked into the image at this step.
- Manual approval gate via a GitHub
productionenvironment. Nothing touches the VPS until approved. - SSH deploy: writes
.envfrom GitHub secrets, pulls the new images, startsdbfirst, waits forpg_isready, runsalembic upgrade head, then restarts backend and frontend with--no-deps. Old images pruned. - Smoke test against
https://api.locdoc.ch/health. - GitHub release with auto-generated notes.
No staging environment. The manual approval gate is the only checkpoint between a passing CI run and production.
Stack
FastAPI, Python 3.11+, SQLAlchemy, Alembic, PostgreSQL, Claude/Anthropic, Stripe, Resend. TanStack Start, TanStack Router, TanStack Query, React, TypeScript, Tailwind v4, shadcn/ui, react-hook-form, Zod. Docker Compose on a single VPS, GitHub Actions for CI and deploy.