Open source billing software for Swiss ZEV and vZEV energy communities.
A ZEV is one grid connection shared by several households, which means somebody has to work out who owed what for the solar. OpenZEV does that end to end: import the meter load curves, split each 15-minute interval between participants, price it against your tariffs, and produce a PDF invoice with a QR-bill payment slip.
I wrote it to bill my own ZEV, and put it out in case it is useful for yours. Self-hosted, AGPL-3.0.
- Built for personal use and self-hosting tinkerers who enjoy running their own stack.
- Shipped as-is, with no warranty (yes, even when it looks great in the dashboard).
- Check your data and billing outputs before they reach a participant. I can't take responsibility for incorrect imports, calculations, invoices, or invoicing workflows.
- Built with generous AI assistance, right down to the specs, ADRs, and user docs. Some choices may therefore look a little unconventional, or fall short of what a more experienced team would do today. That is not accidental: the project is optimized for learning, experimentation, and running my own ZEV, not for enterprise-grade process perfection.
Four roles (admin, zev_owner, participant, guest), each with its own view of
the same data: owners and admins get the operational screens, participants get
self-service access to their own consumption and invoices.
Assignments between participants and metering points carry validity dates, so someone who moves out on 15 March is billed to 15 March and the next tenant picks up from there. Billing interval, invoice language and email templates are set per ZEV.
- CSV and Excel with configurable column mapping, in two format profiles: point readings, and daily 15-minute curves
- SDAT-CH, for when your utility speaks it
- Every import runs as a preview first and writes a per-row protocol, so you see what a file will do before it does it
- A data-quality view flags gaps, duplicates and implausible readings per meter
- Consumption and production as charts by period, or as a daily profile
Allocation runs per timestamp: for each 15-minute interval the local pool is split across participants and priced with the tariff version that was valid at that moment. Tariffs are versioned series with high/low bands, seasonal periods and validity windows, so an invoice raised last year still prices at last year's rate.
Dynamic price series work the same way, including the BFE reference market price, and a grid operator's machine-readable tariff file (Art. 7b StromVV) can be imported directly instead of typed in.
Invoices move through draft → approved → sent → paid, with cancelled branching off any stage. Cancelling keeps the document and its number rather than deleting the row.
Invoices render as PDF/A-3b with a Swiss QR-bill payment slip. Annual statements and participation contracts come out of the same renderer, and every issued version is kept exactly as it was sent.
Estimates savings, payback, ROI and NPV for a community you have not founded yet, modelling individual producers and consumers with a per-participant benefit split and an energy-flow diagram. If you already run a ZEV, it can prefill from its participants, measured self-consumption and all-in tariffs.
Invoice emails go out asynchronously through Celery, with per-invoice delivery history and a retry for failed sends. Templates are per ZEV, with defaults that work without editing.
- Frontend in German, French, Italian and English
- API keys for scripting, with an optional read-only scope and their own rate budget
- An audit log over privileged actions, scoped to what the viewer is allowed to see
- Export or move a whole community between instances as a versioned archive
- OpenAPI schema with Swagger UI and ReDoc
Overview of KPIs, invoice status, and operational health.
Invoice lifecycle management, PDF generation, and email tracking.
Step-by-step import flow with mapping, preview, and validation feedback.
- Backend: Django, Django REST Framework, SimpleJWT
- Frontend: React, TypeScript, Vite, React Query, i18next
- Async jobs and schedules: Celery worker + Beat with Redis broker
- Database: SQLite (default), PostgreSQL, MariaDB via
DATABASE_URL - Deploy: Docker Compose, or the Helm chart on Kubernetes
All end-user documentation lives in docs/user-guide/, organized by workflow, and is published at https://www.openzev.ch/docs/.
- User guide index: docs/user-guide/README.md
- Energy allocation and billing details: docs/user-guide/08-billing-allocation-explained.md
- vZEV feasibility calculator: docs/user-guide/13-feasibility-calculator.md
Report vulnerabilities privately (see SECURITY.md).
Start the full stack and seed a reusable demo environment in one command:
scripts/start-demo-environment.shThe script creates backend/.env from backend/.env.example (development
defaults, DEBUG=True) when it does not exist yet, then starts the default
stack and runs seed_demo. It refuses to run when backend/.env disables
DEBUG (a production configuration) instead of seeding it. For day-to-day frontend/backend development with
live reload instead, use the dev stack:
docker compose -f docker-compose.dev.yml up -d --buildStop it with:
docker compose -f docker-compose.dev.yml downServices: Frontend http://localhost:8080 · Backend API http://localhost:8080/api/v1/ · PostgreSQL and Redis internal to the compose network.
Breaking local API endpoint change: the default API is now
http://localhost:8080/api/v1/via nginx (the backend port is no longer published).docker-compose.dev.ymlstill serves it directly on port 8001.
For a production-like deployment, copy the production template and configure
it for this host (leave CORS_ALLOWED_ORIGINS empty for same-origin
deployments where nginx proxies /api/):
cp backend/.env.production.example backend/.envThen start without demo data:
docker compose up -d --buildThe instance starts without any accounts. Create the first admin (you sign in with its email address):
docker compose exec backend python manage.py createsuperuserThe stack requires backend/.env before starting. Only the frontend (8080)
is reachable from the host; the backend is not published and is reachable only
through the frontend's /api/ proxy, while PostgreSQL and Redis talk over the
compose network only.
Configure the SMTP settings and sender address in backend/.env before using
registration, onboarding, invoice, or security emails; see the Email
Configuration guide.
HTTPS is required for public access. With
DEBUG=Falsethe auth and CSRF cookies areSecure, so browsers only send them over HTTPS — plain HTTP works for local loopback testing, but a public domain needs TLS termination (e.g. a reverse proxy in front of port8080). SetALLOWED_HOSTSto the public hostname,CSRF_TRUSTED_ORIGINSandFRONTEND_URLto the publichttps://origin (even whenCORS_ALLOWED_ORIGINSstays empty for same-origin deployments), and open the app at thathttps://URL.
The shipped nginx sanitizes X-Forwarded-For and the production stack keeps
NUM_PROXIES=1. If another proxy sits in front of it, audit and rate-limit
identity is therefore the immediate outer-proxy address; do not increase
NUM_PROXIES unless the entire proxy chain is deliberately sanitized and
configured to preserve client addresses.
/media/is never web-served in production — invoice files are served only through authenticated API endpoints.
Services: Frontend http://localhost:8080 · Backend API http://localhost:8080/api/v1/ (via nginx).
Upgrading from a stack started before this change: the Postgres data directory is now pinned to
PGDATA=/var/lib/postgresql/data/pgdatainside thepostgres_datavolume, and all three compose files mount that volume at the same path. Previouslydocker-compose.ymlanddocker-compose.dev.ymldisagreed on the mount path, so the two stacks could not see each other's database. An existing volume holds its cluster at the old location, so the first start after this change initialises an empty one. Dump anything you want to keep first:docker compose up -d db docker compose exec db pg_dump -U openzev openzev > backup.sql docker compose down -v # discards the old volume docker compose up -d --build docker compose exec -T db psql -U openzev openzev < backup.sqlFor demo data,
scripts/start-demo-environment.shreseeds from scratch and no dump is needed.
For a step-by-step walkthrough — production setup, the first admin account, roles, exploring each interface, demo accounts, and resetting demo data — see the Getting Started guide.
For a single application container (frontend + backend together), first copy and fill the production checklist as above, then use:
cp backend/.env.production.example backend/.env
docker compose -f docker-compose.fullstack.yml up -d --buildapp serves the frontend and proxies API requests to Django inside the same container; worker, db, and redis stay separate. Frontend URL: http://localhost:8080. Stop with docker compose -f docker-compose.fullstack.yml down.
See the Getting Started guide for details.
OpenZEV ships as a Helm chart in charts/openzev.
The chart deploys the frontend, backend, a Celery worker, and one Celery Beat
scheduler, plus an Ingress and a PVC for /app/media. It does not deploy
PostgreSQL or Redis — you must provide reachable external database and Redis
endpoints.
helm repo add openzev https://splattner.github.io/openzev
helm repo update
helm install openzev openzev/openzev -n openzev --create-namespaceFor install options and example values (external DB/Redis secrets, email, ingress), see the Helm chart README.
Prebuilt images are published to GitHub Container Registry (GHCR), the current image names are:
ghcr.io/splattner/openzev-backendghcr.io/splattner/openzev-frontendghcr.io/splattner/openzev-fullstack
Available image variants:
openzev-backend: Django API applicationopenzev-frontend: static frontend served with Nginxopenzev-fullstack: frontend assets + backend in one container for simpler test deployments
Available tags:
- Release tags such as
v1.2.3 latestfor the newest published releasemainfor the newest build from themainbranchmain-<short-sha>for a specificmainbranch commit build
Images tagged main are intended for testing and preview deployments before a formal release.
- They are rebuilt on every commit pushed to
main - They may contain unfinished changes or breaking behavior
- They should be considered unstable and not be treated like a versioned release artifact
If you need reproducible deployments, prefer a release tag such as v1.2.3 instead of main.
- Release images are published with signed container manifests and signed SBOM attestations
mainbranch images are also pushed, signed, and accompanied by generated SBOMs- Release SBOM files are attached to the GitHub release
mainbranch SBOM files are uploaded as workflow artifacts in theContainer Build Checkworkflow run- SBOM verification is performed through the signed attestation bound to the image, not through a separate detached signature on the raw
.spdx.jsonfile
Install cosign locally, then verify an image with GitHub OIDC keyless signatures:
cosign verify \
--certificate-identity-regexp "https://github.com/splattner/openzev/.github/workflows/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/splattner/openzev-backend:mainFor a release image, replace the tag with the release version, for example :v1.2.3.
You can verify the signed SBOM attestation attached to an image:
cosign verify-attestation \
--certificate-identity-regexp "https://github.com/splattner/openzev/.github/workflows/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
--type spdxjson \
ghcr.io/splattner/openzev-backend:mainTo inspect the attested predicate after verification, add | jq '.payload | @base64d | fromjson' or download the generated .spdx.json artifact directly from the workflow or release.
cd backend
cp .env.example .env
python -m venv ../.venv
source ../.venv/bin/activate
pip install -r requirements.txt
python manage.py migrate
python manage.py runserverOptional admin user:
python manage.py createsuperuserUse the Node version pinned in .node-version.
cd frontend
cp .env.example .env
npm install
npm run devFrontend dev URL: http://localhost:5173
For docker-compose.dev.yml with non-default frontend ports, export CORS_ALLOWED_ORIGINS with your frontend origin before starting the stack. CSRF_TRUSTED_ORIGINS follows it unless explicitly overridden.
Cookie sessions require same-origin (or same-host reverse proxy, e.g.
VITE_API_BASE_URL=/api/v1). Same-host different-port dev (localhost:5173→localhost:8001) works withCORS_ALLOWED_ORIGINS/CSRF_TRUSTED_ORIGINS. Truly cross-hostname (app.example.com→api.example.com) cannot be fixed by those settings alone — JS cannot read a cross-origincsrftokencookie — use a same-origin reverse proxy.
cd backend
source ../.venv/bin/activate
celery -A config worker -l info
# In a second terminal; run exactly one scheduler per deployment.
celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseSchedulerUse seeded data for quick local testing of flows.
cd backend
source ../.venv/bin/activate
python manage.py seed_demoBy default, --end-date is today and --start-date is the start of the
complete quarter before that end date. Both dates accept YYYY-MM-DD; use
--end-date to reproduce a historical window and --start-date to override
its default start.
Seeded demo users:
- Admin:
admin@openzev.local/admin1234 - ZEV Owner:
owner@openzev.local/owner1234 - Participant (ZEV 1):
anna@openzev.local/anna1234 - Participant (ZEV 1):
ben@openzev.local/ben1234 - Participant (ZEV 2):
clara@openzev.local/clara1234
The seed command creates two communities owned by the same demo owner, so the community switcher can be exercised:
- ZEV STWEG Sonnenhof — the flagship, a single-building condominium (
zev) with quarterly billing, German invoices and VAT folded into its prices. Carries participants, metering points, tariffs and hourly readings from 1 January of the previous year through the seed window, with 15-minute readings only for its latest 14 days. The previous year is billed quarter by quarter as paid invoices, except when its final quarter overlaps the open prior quarter; that prior quarter has draft, approved and sent invoices. - ZEV Sonnenfirma AG — a smaller property-company (
vzev) with monthly billing, English invoices, VAT-registered with a UID, shared grid-connection and per-metering-point fees, and itemized tariff bands. Carries its own participants, metering points, tariffs and readings, plus two invoice periods: the prior complete month in draft/approved/sent and the month before it closed (paid/cancelled).
The standard Swiss VAT ranges (7.7 % from 2018 and 8.1 % from 2024) are added only when no existing rate overlaps each range; existing VAT timelines are preserved. The operational/log pages are seeded too — metering import logs (CSV + SDAT-CH, with CSV provenance on a real meter month), invoice email logs, two issued contract snapshots (Anna and Clara), and audit events (including one denied) — and one meter on the flagship carries an intentional ~12-day reading gap in the current quarter so the data-quality page has a real issue to show.
Re-running seed_demo refreshes the demo readings, invoices, import/email logs and audit events, while retaining contract snapshots. Screenshot captures explicitly select ZEV STWEG Sonnenhof so they consistently show the same community.
- Swagger UI: http://localhost:8080/api/docs/
- ReDoc: http://localhost:8080/api/redoc/
- Base API prefix:
/api/v1/
Development stack (docker-compose.dev.yml) serves these directly on port 8001.
- Without Docker, the backend defaults to SQLite (see
backend/.env.example). Docker Compose uses PostgreSQL. MariaDB is also supported. - Async tasks (invoice emails, PDF generation, geocoding) require Redis and a Celery worker. Periodic work such as dynamic-tariff refreshes also requires exactly one Celery Beat scheduler. Docker Compose includes all three; for other setups, ensure they are running.
- Use
.env.exampleas baseline for environment configuration. - Keep migrations up to date when changing models:
cd backend
source ../.venv/bin/activate
python manage.py makemigrations
python manage.py migrate- Run backend tests from repository root:
pytest- Build frontend before release:
cd frontend
npm run buildOpenZEV uses feature specifications and architecture decision records (ADRs) to document and communicate larger changes. Both are linked from pull requests when a change is significant or cross-cutting.
- Specifications (
docs/specs/) — required for changes to API behavior, billing/tariff logic, invoice workflow, data models/migrations, async jobs, or security/role/ZEV-scope. Full process and the baseline-spec index:docs/specs/README.md. - ADRs (
docs/adr/) — record high-impact architecture decisions with long-term consequences. Full process and the index:docs/adr/README.md. - Agent guidance — coding agents should follow the working agreements and
spec-maintenance rules in
AGENTS.md.
For pull requests, link affected specs/ADRs using
.github/PULL_REQUEST_TEMPLATE.md.
Releases are automated via GitHub Actions (see .github/workflows/):
- PR titles must follow Conventional Commits; Release Please manages SemVer tagging and changelog generation.
- Pull requests run lint/check/test and container build checks without pushing images.
- Commits to
mainbuild, push, sign, and SBOM-attach preview images (tagsmain,main-<sha>). - Published releases build and push versioned images to GHCR (see Prebuilt Container Images).
- Renovate keeps npm/pip/GitHub Action dependencies up to date.






