Invoice on Stellar. Get paid. Keep the proof.
Quittance helps freelancers create an invoice, accept payment via link or QR on Stellar, verify it on Horizon (memo + amount + destination), then download or email payment proof. Settlement stays on-chain. Quittance does not expose other people’s wallet identity or history.
Sharp initial user: freelancer invoicing a client in XLM/USDC on Stellar.
| Capability | Status |
|---|---|
| Freighter wallet as identity (create + pay) | Done — no Google login gate |
| Create invoice + payment URL / QR | Done |
Optional client email + Send invoice / Email proof (mailto:) |
Done |
| Horizon-backed payment verify | Done (memo, amount, destination, asset) |
| Dashboard scoped to connected wallet | Done |
| Primary Download Proof CTA after paid | Done (PDF print flow in browser) |
| Simulate-payment UI | Removed from demo UI (ALLOW_SIMULATE=true only on API) |
| Public hosted demo + testnet evidence pack | Phase D (not yet) |
| Postgres persistence (wallet-scoped invoices) | Optional — see Postgres persistence |
| SMTP / Gmail API | After demo (Phase E) |
Ship plan: PLAN.md.
- Connect Freighter and create an invoice (optional client name/email)
- Share the payment URL or QR — or Send invoice if email is set
- Client pays on Stellar (Freighter, QR, or manual transfer with the memo)
POST /api/invoices/:id/verifychecks the tx on Horizon- Download Proof (primary) or Email Proof (if client email exists)
Identity is the wallet. Email is an optional delivery channel, not a login gate.
An invoice becomes PAID only when all four checks pass against Horizon:
memo, destination, amount, and asset (code and issuer for
non-native assets). A fifth guard rejects a transaction observed on a different
Stellar network.
One module owns these rules: backend/src/services/payment-verification.ts.
It is pure — the caller fetches the transaction and operations from Horizon and
passes them in. Every verify path routes through it, so the MVP and Postgres
handlers reject the same cases with the same wording:
| Path | Entry point |
|---|---|
| MVP (in-memory) | POST /api/invoices/:id/verify — backend/src/server-mvp.ts |
| Postgres | POST /api/invoices/:id/verify — backend/src/routes/invoice.handlers.ts |
| Standalone check | POST /api/stellar/verify-payment — backend/src/services/stellar.service.ts |
| Pay flow (client) | frontend/lib/verification.js — mirrors codes and messages |
Checks run in a fixed order, so every caller reports the same first failure:
tx hash → network → payment operation → memo → destination → amount → asset
Rejections return a stable code alongside the human-readable error:
| Code | Message | HTTP |
|---|---|---|
MISSING_TX_HASH |
Transaction hash is required | 400 |
INVALID_TX_HASH |
Transaction hash must be 64 hexadecimal characters | 400 |
INVALID_PAYER_NAME |
Payer name must be text | 400 |
INVALID_PAYER_EMAIL |
Payer email is invalid | 400 |
PAYER_INFO_TOO_LONG |
Payer information is too long | 400 |
INVOICE_ALREADY_PAID |
Invoice has already been paid | 400 |
INVOICE_EXPIRED |
Invoice has expired and can no longer accept payment | 400 |
INVOICE_NOT_PENDING |
Invoice is not pending | 400 |
TRANSACTION_NOT_FOUND |
Transaction not found on Stellar | 404 |
NO_PAYMENT_OPERATION |
No payment operation found in transaction | 400 |
MEMO_MISMATCH |
Memo mismatch | 400 |
DESTINATION_MISMATCH |
Payment destination mismatch | 400 |
AMOUNT_MISMATCH |
Amount mismatch | 400 |
ASSET_MISMATCH |
Asset mismatch | 400 |
NETWORK_MISMATCH |
Transaction is on a different Stellar network | 400 |
The client mirror lets the pay page reject malformed input before a round trip and show the exact message the server would return. A test asserts the two tables stay identical — if you add a code, add it in both files.
Amounts compare at Stellar's 7-decimal (stroop) precision, so 100 and
100.0000000 match while a partial payment does not.
Run the checks: cd backend && npm test — cd frontend && npm test.
Sellers choose a payment window of 1–30 days (7 days by default). Both
storage backends persist expiresAt and lazily transition elapsed PENDING
invoices to EXPIRED before get, list, stats, verify, cancel, or monitor work.
Expired invoices remain visible in seller history, but they are excluded from
pending/actionable counts and cannot expose QR, pay, verify, or payment-proof
controls. The client also projects stale pending data through expiresAt so a
page fails closed while it waits for the next authoritative server response.
| Layer | Tech |
|---|---|
| Frontend | Next.js 14, TypeScript, Tailwind, Freighter |
| Backend (local / demo) | Express, TypeScript, in-memory MVP (server-mvp.ts) |
| Chain | Stellar testnet / public via Horizon |
| Later | PostgreSQL full server (server.ts, not required for v0.1) |
Both entrypoints share one invoice route layer. Only the storage adapter differs:
server-mvp.ts ─┐ ┌─ memory-invoice-storage.ts (in-memory)
├─ routes/invoice.routes.ts ─ routes/invoice.handlers.ts ─ InvoiceStorage
server.ts ─────┘ └─ postgres-invoice-storage.ts (PostgreSQL)
src/routes/invoice.handlers.ts— the only implementation of create / get / list / verify / cancel / stats / payment-info / simulate.src/storage/invoice-storage.ts— theInvoiceStorageinterface both backends implement. Seller keys are always wallet-scoped: they come from the invoice payload or thesellerPublicKeyquery parameter, never from a static env key.src/types/api.ts— the sharedApiResponseenvelope ({ success, data, message?, pagination? }or{ success: false, error }) used by every route on both servers.
A bug fix in a handler applies to both servers at once.
- Node.js 18+
- Freighter for wallet flows — see Freighter docs
- Stellar testnet account for real payments (Laboratory)
PostgreSQL and Redis are not required for the MVP path below.
- Install the Freighter browser extension and create or import a wallet.
- Open Freighter's network menu and select Testnet. The official Connect to the Testnet guide shows the same flow.
- Copy your public account address (it starts with
G). Use Freighter's Fund with Friendbot prompt, or open Stellar Lab's Fund Account page, paste the address, and select Get testnet XLM.
Only use your public G... address with Friendbot. Never paste a secret key or recovery phrase into a funding form. Testnet XLM has no real-world value and may disappear when Stellar resets Testnet.
Follow these steps from a fresh clone. Use two terminals so the backend and frontend can run at the same time.
git clone https://github.com/Kappa16/Quittance0.git
cd Quittance0cd backend
npm i
cp env.mvp.example .env
npm run dev:mvpKeep this terminal running.
- API:
http://localhost:3001/api - Health check:
http://localhost:3001/api/health - MVP server entrypoint:
backend/src/server-mvp.ts
Optional: set ALLOW_SIMULATE=true in backend/.env only for local fake payments (not for demos).
Open a second terminal from the repo root:
cd frontend
npm i
cp env.mvp.local .env.local
npm run devOpen the app at http://localhost:3000.
- The MVP backend is intentionally in-memory: invoices and payment state clear every time the backend process restarts.
- PostgreSQL and Redis are not needed for the local MVP path.
FRONTEND_URLinbackend/.envmust match the frontend origin for CORS; the provided MVP env useshttp://localhost:3000.NEXT_PUBLIC_API_URLinfrontend/.env.localmust include/api; the provided MVP env useshttp://localhost:3001/api.
- Backend MVP template:
backend/env.mvp.example→ copy tobackend/.env - Frontend MVP template:
frontend/env.mvp.local→ copy tofrontend/.env.local - Full frontend template:
frontend/env.example.txt
Use this path when invoices must survive a backend restart. Identity is still the
connected Freighter wallet: every invoice is stored under its seller_public_key,
and list/stats endpoints only return the requesting wallet's invoices.
In backend/.env (template: backend/env.example.txt):
DATABASE_URL=postgresql://user:password@localhost:5432/quittance
SELLER_PUBLIC_KEY / SELLER_SECRET_KEY are optional. They are only used by
the single-account Horizon payment monitor; without them the server starts in
wallet-scoped mode and the monitor stays off.
cd backend
npm run db:migrate # applies db/schema.sql (idempotent, safe to re-run)
npm run db:seed # optional: sample invoices for two demo walletsdb/schema.sql— invoices, transactions, payment_events,invoice_statsview. Re-running it also drops the legacyuserstable andinvoices.user_idcolumn from older databases.db/seed.sql— invoices for two demo seller wallets so wallet scoping is visible locally. Swap a seedseller_public_keyfor your own Freighter address to see the rows in your dashboard.- Runners:
backend/src/db/migrate.ts,backend/src/db/seed.ts.
cd backend
npm run dev # src/server.ts (Postgres) instead of dev:mvp (in-memory)The dashboard sends the connected wallet on every call:
GET /api/invoices?sellerPublicKey=G... and GET /api/invoices/stats?sellerPublicKey=G...
both return 400 when the seller key is missing.
cd backend
npm run typecheck # TypeScript compile check (no emit)
npm test # unit + scoping + parity tests
npm run test:isolated # standalone regression tests in tests/isolated
DATABASE_URL=postgresql://user:password@localhost:5432/quittance_test npm test # adds the Postgres integration testThe integration test (backend/tests/invoice-postgres.integration.test.ts) is
skipped unless DATABASE_URL is set. Point it at a disposable database — it
applies the schema, runs the seed twice to check idempotency, and writes rows
covering create, list, status filter, pagination, verify (markAsPaid with all
payer fields), expiry-time guard, cancel once-only, markExpiredInvoices lazy
transition, and the PostgresInvoiceStorage adapter end to end.
npm test also exercises the shared invoice handlers against both the in-memory
and PostgreSQL storage adapters, so create/verify/cancel/list/expiry/stats
regressions surface without a live database. Every backend path uses the same
StoredInvoice fields (seller metadata, asset issuer, expiry, payer info,
metadata) — the handler suite is parameterised over both adapters, so field
parity between memory and Postgres stays pinned by the same assertions.
- Create/import the project in Vercel and set Root Directory to
frontend. - Keep the Next.js preset;
frontend/vercel.jsonusesnpm ci, builds.next, and applies the public security headers. - Add these variables to Production (and Preview when preview deploys should call the API):
| Variable | Example |
|---|---|
NEXT_PUBLIC_API_URL |
https://YOUR-API-HOST/api |
NEXT_PUBLIC_STELLAR_NETWORK |
TESTNET |
NEXT_PUBLIC_HORIZON_URL |
https://horizon-testnet.stellar.org |
NEXT_PUBLIC_APP_URL |
https://YOUR-APP.vercel.app |
NEXT_PUBLIC_USE_MOCK |
false |
- Run
npm run deploy:checklocally with the same variables before deploying. - Deploy. A missing/invalid production API URL fails closed in the UI with an explicit configuration warning; it never falls back to a visitor's localhost.
Templates: frontend/env.example.txt, frontend/env.mvp.local.
Recommended host for server-mvp.ts (in-memory). backend/vercel.json now has
an optional serverless MVP entrypoint, but Render is the documented demo path
because it exposes normal liveness/readiness checks and predictable logs.
- Create a Web Service on Render from this repo.
- Root Directory:
backend - Build:
npm ci && npm run build - Start:
npm run start:mvp:prod - Health check path:
/api/ready(/api/healthremains liveness) - Environment variables:
| Variable | Value |
|---|---|
NODE_ENV |
production |
STELLAR_NETWORK |
TESTNET |
STELLAR_HORIZON_URL |
https://horizon-testnet.stellar.org |
FRONTEND_URL |
https://YOUR-APP.vercel.app (exact frontend origin) |
FRONTEND_URLS |
Optional comma-separated preview/custom origins |
ALLOW_SIMULATE |
false |
PORT is set by Render automatically.
backend/render.yaml is a complete Blueprint for this path. Set the unsynced
FRONTEND_URL value in Render; origins are exact and wildcards are rejected.
- Copy the public API URL (e.g.
https://quittance-api.onrender.com). - Set frontend
NEXT_PUBLIC_API_URLtohttps://…/apiand redeploy Vercel. - Confirm CORS: browser call from the Vercel origin to
/api/healthsucceeds. - Confirm
GET /api/readyreturns HTTP 200 withready: true. - Run the deployed create/read round-trip:
DEPLOY_API_URL=https://YOUR-API-HOST/api node scripts/deploy-smoke.mjsThe smoke command creates one tiny in-memory XLM invoice, reads it back, and checks health/readiness. It never simulates or submits a Stellar payment.
- Reserve/deploy the Vercel project URL.
- Configure that exact origin as Render
FRONTEND_URL, then deploy Render. - Put the Render URL plus
/apiinto VercelNEXT_PUBLIC_API_URLand redeploy. - Run the smoke command and the browser checklist in
EVIDENCE.md.
Note: Free-tier / in-memory means cold starts and process restarts clear all invoices. Fine for a short demo; document this for reviewers.
Env template: backend/env.mvp.example.
A Stellar asset is the pair (code, issuer), never the code alone — anyone can
issue a credit asset coded USDC, or even XLM. How invoices name assets and
how settlement compares them is documented in
docs/ASSETS.md and docs/VERIFY.md.
Every pull request and every push to main runs the same three jobs defined in
.github/workflows/ci.yml. All of them are
reproducible locally with the commands below — CI runs nothing you cannot run
yourself.
# Backend: typecheck + unit and integration tests
cd backend && npm ci && npm run typecheck && npm test
# Frontend: lint + typecheck + unit tests
cd frontend && npm ci && npm run lint && npm run typecheck && npm test
# Frontend: focused axe, focus-management, live-region, and contrast checks
cd frontend && npm run test:a11y
# Shared export helpers (repository root)
node --test "tests/**/*.test.mjs"The focused accessibility suite renders the landing, dashboard, pay, and
invoice-detail routes in jsdom, audits them with axe, and directly checks the
focus and live-region behavior that a static axe scan cannot observe. Because
jsdom has no layout engine, WCAG contrast ratios are verified separately from
the color pairs declared in frontend/tailwind.config.js.
backend/tests/invoice-payment-loop.test.ts exercises the whole core loop —
create invoice → pay → verify → status PAID — against the real Express
app, the real validation and the real in-memory store.
Horizon is the only thing replaced. The test starts a small stub on a loopback
port and points STELLAR_HORIZON_URL at it, so no network call leaves the
machine and the suite is deterministic. Alongside the happy path it pins the
rejections that protect a seller: a memo belonging to another invoice, a wrong
destination, a wrong amount, a wrong asset, and a second verification of an
invoice that is already paid.
If verify ever stops setting PAID, or starts accepting a payment it should
refuse, this test fails.
No secrets are required. The workflow sets only:
| Variable | Job | Why |
|---|---|---|
STELLAR_NETWORK=TESTNET |
backend | Never resolve mainnet configuration |
STELLAR_HORIZON_URL=http://127.0.0.1:1 |
backend | Fail closed; the integration test overrides it with its own stub |
NEXT_PUBLIC_API_URL |
frontend | Build-time default for the client |
NEXT_PUBLIC_STELLAR_NETWORK=TESTNET |
frontend | Keep the client on testnet |
A plaintext Horizon URL is accepted only for a loopback address
(backend/src/config/stellar.ts), so a real deployment can never be downgraded
to HTTP by configuration.
For a manual testnet pass with a real Freighter payment, see
EVIDENCE.md.
Reviewer pack: EVIDENCE.md (URLs, testnet tx hashes, recording, tech note).
| Item | Status |
|---|---|
| Public demo URL | Fill in EVIDENCE.md after deploy (D4) |
| Testnet tx hashes | Fill in after a real Freighter pay (D5) |
| Screen recording | Fill in after demo recording (D5) |
Until then, run locally: backend → npm run dev:mvp, frontend → npm run dev.
backend/ Express API — use server-mvp.ts for demo
src/routes/ shared invoice route layer (both servers)
src/storage/ InvoiceStorage: in-memory + PostgreSQL adapters
src/services/payment-verification.ts — canonical verify rules
frontend/ Next.js app
lib/verification.js — client mirror of the verify contract
db/ Postgres schema + seed SQL (runners: backend/src/db/)
PLAN.md Product & delivery plan
ROADMAP.md Short commit checklist
EVIDENCE.md Public demo URL + testnet evidence (reviewer one-pager)
MIT — see LICENSE.