No app. No scanning. No OCR. Structured receipt data at the source.
ZZPay is a digital receipt infrastructure layer for self-employed professionals (ZZP'ers). A merchant turns a checkout into a clean, EN 16931-compatible structured receipt with one click. The receipt is delivered over NFC (tap a tile) or an online link, and the customer opens it in a plain browser — no app, no login. From there they can download, export a real e-invoice, email it to their accountant, or request a refund that becomes an auditable credit note.
This repository is the MVP built for FinTech Assignment 2. It is deliberately narrow and demo-ready: it proves the core value-enhancing process in working code, before any expensive POS or Peppol integrations.
ZZPay was built by Cas van Ruijven, Martijn van der Pijl and David Kloet. We made it together in
live sessions on one laptop, so all commits are under Cas his account. We made all the design,
architecture and review choices together. You can read more about how we used the AI agents in
docs/ai-orchestration.md.
The assignment asks for a working MVP of the Assignment 1 business model, demonstrating the main features, explaining the architecture and deployment, oriented at investors, and documenting which coding agents were used. ZZPay's pitch and the lecturer feedback shaped the scope:
- Merchants must support it with a low hurdle → the merchant flow is a lightweight portal that does not replace the POS (simulated here).
- Explain returns/refunds → refunds are modeled as linked credit notes (the original receipt is never deleted).
- Explain multi-channel & network effects → NFC and online checkout deliver the same receipt
object; see
docs/multi-channel.md. - Mention the eInvoice standard → structured export is EN 16931 core UBL 2.1; see
docs/einvoice-standard.md. - Costs & support are real risks →
docs/merchant-adoption-and-support.md.
| Feature | Who | Where |
|---|---|---|
| One-click receipt creation at checkout (NFC + online) | Merchant | /merchant, /online-checkout |
| Public receipt page — no login | Customer | /receipt/:id |
| Download PDF / PNG | Anyone | ExportButtons |
| Export JSON + EN 16931 UBL e-invoice | Account holder | ubl.py |
| Email to self / send to accountant (PDF + UBL) | Account holder | email.py |
| Refund → linked credit note | Anyone | refunds.py |
| Delete a receipt from your account | Owner | receipts.py |
| Optional account + business details + browser-local preferences | Customer | /account |
- Anonymous (no login): view the receipt, download PDF/PNG, request a refund. The My receipts list shows only receipts opened on this device (localStorage) — never anyone else's.
- Logged-in account holder: additionally export JSON/UBL (a compliant e-invoice needs a buyer identity), email to self, and send to accountant. Receipts link to the account (on checkout / view / export), so My receipts is private and account-scoped.
A few rules that keep the books clean:
- No duplicate sends — a receipt or credit note can be emailed only once per recipient (a second send returns 409).
- Auto-forwarded credit notes — refunding while logged in (with an accountant configured) automatically emails the linked credit note to that accountant (PDF + UBL).
- Your business identity on the e-invoice — the account page captures company name, BTW & KVK number and address; these become the EN 16931 buyer party in the UBL/JSON exports.
- NFC checkout —
/merchant→ Generate ZZPay Receipt → Simulate customer tap → the public receipt page opens (no login). - Online checkout —
/online-checkout→ Place order → View your ZZPay receipt → the same public page, delivered through a different channel. - Public receipt —
/receipt/ZZP-2026-0001(seeded) → download PDF/PNG; log in to export the UBL e-invoice or email your accountant. - Refund — on a receipt, Request a refund → pick a line (e.g. "Kit") → a linked credit note
CN-…is created; the original is preserved and markedREFUNDED.
Seeded out of the box: ZZP-2026-0001 (Bouwmaat, NFC, already refunded via CN-2026-0001) and
ZZP-2026-0002 (Shell, online link). Demo account: demo@zzpay.nl / demo1234.
Prerequisites: Python 3.12, Node 20+, Docker (for PostgreSQL). PDF/PNG rendering is pure-Python (xhtml2pdf + pypdfium2) — no native libraries required, runs on Windows out of the box.
# 1) Database (PostgreSQL)
docker compose up -d
# 2) Backend
cd server
python -m venv .venv && source .venv/bin/activate # Windows: .\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
cp .env.example .env # set ZZPAY_JWT_SECRET (any long random string)
python -m uvicorn app.main:app --reload --port 8000 # tables + demo data created on startup
# 3) Frontend (new terminal)
cd client
npm install
npm run dev # http://localhost:5173Open http://localhost:5173. Try the merchant demo, tap-to-open a receipt, then log in with the demo account to export a UBL e-invoice and "send to accountant" (emails are mocked — printed to the backend console unless you configure real SMTP).
Resetting the DB: the schema is created from SQLModel metadata on startup (
create_all, no migrations). If you change the schema, reset the dev database withdocker compose down -v && docker compose up -d.
Tests: cd server && pytest — runs against in-memory SQLite, no Docker needed.
- Config is environment-driven (
ZZPAY_*), so one image runs anywhere. - Backend: any container host (Fly.io / Render / a VM). Provide a managed PostgreSQL via
ZZPAY_POSTGRES_*, a strongZZPAY_JWT_SECRET, realZZPAY_SMTP_*, and setZZPAY_CORS_ORIGINSto the front-end origin./api/healthsupports health checks. - Frontend:
npm run build→ staticdist/on any static host/CDN; point/apiat the backend. - Production hardening (documented, not built): Alembic migrations, rate limiting, email verification, background workers for rendering, and a real Peppol Access Point for auto-send.
Merchant Portal (/merchant, /online-checkout)
│ POST /api/checkout (the "source" — POS simulation)
▼
Receipt Service ── einvoice.py maps the basket → structured Receipt (VAT split, totals)
│ models.py (Receipt, CreditNote, User) ── PostgreSQL
▼
eInvoice / UBL object
│ delivery_url
▼
NFC URL / Online Link → Public Receipt Page (/receipt/:id, no login)
│
├── Export pdf.py (PDF/PNG) · einvoice.py (JSON) · ubl.py (EN 16931 UBL)
├── Email email.py → user / accountant (PDF + UBL attachments)
└── Refund refunds.py → linked CreditNote (original preserved)
Full detail in docs/architecture.md. Stack: FastAPI + SQLModel +
PostgreSQL (async) backend; React 19 + Vite + Tailwind + React Query frontend. Deliberately flat
and small so the whole system reads top-to-bottom.
A PDF is human-readable; bookkeeping software needs machine-readable structure. ZZPay exports an
EN 16931 core UBL 2.1 invoice (CustomizationID = urn:cen.eu:en16931:2017) where the merchant
is the seller and the logged-in ZZP'er is the buyer — a purchase invoice they can hand to their
accountant. We emit every mandatory business term and reconcile every calculation rule. A receipt
without a seller VAT number is rejected (422) rather than producing an invalid e-invoice.
We are not a certified Peppol Access Point — that, plus auto-send, is the documented next step.
See docs/einvoice-standard.md.
Refunds never delete or edit the original receipt. ZZPay issues a linked credit note (UBL
BillingReference → the original) so the correction stays auditable. If the receipt was already
emailed to the accountant, ZZPay warns and offers to send the credit note the same way. See
docs/refund-flow.md.
NFC is one delivery channel. The same structured receipt object is delivered through an online
link in the webshop flow, and could be delivered through QR, email, or a POS API — they are all just
channels over the same infrastructure. See docs/multi-channel.md.
- NFC tap is a button ("Simulate customer tap"); no NFC hardware.
- POS integration is simulated by a small merchant registry; no real till webhook.
- Email is mocked (logged to console) unless real SMTP is configured.
- UBL is EN 16931 core and not validated by a certified Peppol Access Point; multi-rate VAT, postal addresses, and buyer VAT/KVK are simplified.
- Schema is created via
create_all(no migrations); no rate limiting / email verification. - Anonymous users get PDF/PNG only — structured export + email require the optional account.
Built primarily with Claude Code (planning, full-repo edits, self-verification), with ChatGPT
used for assignment interpretation and prompt design; humans made all product decisions. Full
write-up: docs/ai-orchestration.md. Agent instructions live in
CLAUDE.md, AGENTS.md, and .agents/zzpay-mvp-agent.md.
server/app/
main.py app + routers + lifespan (init_db + seed)
config.py ZZPAY_* settings (Postgres, JWT, SMTP)
database.py async engine + get_session
models.py Receipt, CreditNote, User (tables + Pydantic schemas)
auth.py Argon2 + JWT; CurrentUser / OptionalUser deps
einvoice.py basket → Receipt mapper, VAT/totals, JSON export
ubl.py EN 16931 core UBL (invoice + credit note)
pdf.py structured data → PDF/PNG (pure-Python)
email.py mock/SMTP send with PDF + UBL attachments
seed.py demo account + ZZP-2026-0001/0002 + CN-2026-0001
routers/ checkout.py, receipts.py, refunds.py, users.py
templates/ receipt.html, credit_note.html, invoice_ubl.xml, creditnote_ubl.xml
client/src/
api.ts typed API client
auth.tsx optional-account context (non-blocking)
App.tsx public routes
lib/ format.ts, download.ts (fetch+blob), storage-service.ts
pages/ Landing, MerchantCheckout, OnlineCheckout, ReceiptView, MerchantReceipts, Account
components/ ReceiptDocument, ReceiptActions, ExportButtons, RefundPanel, StatusBadge, …
docs/ architecture, assignment-mapping, einvoice-standard, refund-flow,
multi-channel, merchant-adoption-and-support, ai-orchestration
Educational project for FinTech Assignment 2. © the ZZPay team. No warranty; not for production use.