Warning This project is under active development and not yet ready for production use.
Ledger is a billing and payment service for the ConvStack platform. It provides a unified payment abstraction layer supporting multiple providers (Stripe, manual bank transfer/cash), with invoicing, subscriptions, products, and GDPR-compliant data retention.
- Payment providers — Stripe (checkout, subscriptions, refunds, proration) and Manual (IBAN, cash, custom instructions)
- Invoices — Sequential numbering (INV-2026-0001), line items, tax calculation, email notifications
- Products — One-time and recurring products with per-product proration toggle
- Subscriptions — Create, cancel, upgrade/downgrade with proration support (Stripe)
- Webhook subscribers — Fan-out event delivery to multiple external services
- Service-to-service API — Other ConvStack services create invoices via ServiceKey auth
- GDPR compliance — Configurable data retention with automatic anonymization
- Dynamic manifest — UI adapts based on active provider capabilities
Ledger is a backend-only REST API — the Dashboard renders all UI from its JSON manifest. Ledger registers with Lanyard's service catalog on boot.
Browser → Dashboard (UI) → API Proxy → Ledger (REST API + PostgreSQL)
→ Lanyard (Auth + Service Catalog)
External services → Ledger API (ServiceKey auth)
Stripe → Ledger webhooks (signature-verified)
Ledger → Webhook subscribers (fan-out)
- Runtime: Bun
- Framework: TanStack React Start (for API route handling)
- Database: PostgreSQL via Drizzle ORM
- Payments: Stripe SDK
- Email: Nodemailer (SMTP)
- Encryption: AES-256-GCM for provider settings at rest
- Linting: Biome
- Bun installed
- PostgreSQL database
- Lanyard running (for authentication and service registration)
- Dashboard running (for the UI)
bun installcp .env.example .envEdit .env:
DATABASE_URL=postgres://ledger:ledger@localhost:5432/ledger
LANYARD_URL=http://localhost:3000
LANYARD_SERVICE_KEY=sk_svc_your_key_here
ENCRYPTION_KEY=<64-char hex string>
DASHBOARD_URL=http://localhost:4000
PORT=5002Generate an encryption key:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"bun run db:migrate- Sign in to Dashboard as an admin
- Go to Lanyard Admin → Services → Register Service
- Fill in:
- Name: Ledger
- Slug:
ledger - Type: Service
- Base URL:
http://localhost:5002 - Health Check Path:
/api/health
- Click Register Service
- Copy the API key (
sk_svc_...) and set it in.envasLANYARD_SERVICE_KEY
bun run devLedger will:
- Start on port 5002
- Create default billing settings
- Send a heartbeat to Lanyard with its dynamic UI manifest
- Appear in the Dashboard sidebar as "Ledger"
- In the Dashboard, navigate to Ledger → Providers
- Click Add Provider and choose Stripe or Manual
- Open the provider and configure its settings (API keys, IBAN details, etc.)
- Click Activate to make it the active provider
Ledger serves its OpenAPI specification at:
GET /api/openapi
This is also aggregated into Lanyard's centralized Swagger UI at:
http://localhost:3000/docs
| Script | Description |
|---|---|
bun run dev |
Start dev server (port 5002) |
bun run build |
Production build |
bun run start |
Start production server |
bun run db:generate |
Generate migration files |
bun run db:migrate |
Run migrations |
bun run db:push |
Push schema to DB (dev) |
bun run typecheck |
TypeScript type checking |
bun run lint |
Biome linting |
bun run openapi:generate |
Regenerate OpenAPI spec |
Other ConvStack services can create invoices for users:
curl -X POST http://localhost:5002/api/invoices \
-H "Authorization: ServiceKey sk_svc_..." \
-H "Content-Type: application/json" \
-d '{
"userId": "user-id",
"items": [
{
"description": "Convention Ticket",
"unitPrice": 5000,
"quantity": 1,
"productId": "optional-product-id"
}
],
"currency": "EUR",
"notes": "VIP package upgrade",
"dueDate": "2026-05-01T00:00:00Z",
"skipTax": false
}'| Field | Type | Required | Description |
|---|---|---|---|
userId |
string | Yes | The user to bill |
items |
array | Yes | At least one line item |
items[].description |
string | Yes | Line item description |
items[].unitPrice |
integer | Yes | Price in cents (e.g. 5000 = €50.00) |
items[].quantity |
integer | No | Defaults to 1 |
items[].productId |
string | No | Link to a Ledger product |
currency |
string | No | Defaults to the configured default (EUR) |
notes |
string | No | Internal notes on the invoice |
dueDate |
string | No | ISO 8601 due date |
skipTax |
boolean | No | Skip automatic tax calculation |
Response:
{
"success": true,
"id": "invoice-id",
"invoiceNumber": "INV-2026-0001",
"status": "pending",
"subtotal": 5000,
"tax": 950,
"total": 5950
}| Method | Endpoint | Description |
|---|---|---|
POST |
/api/invoices |
Create invoice for a user |
POST |
/api/invoices/:id/mark-paid |
Mark invoice as paid |
GET |
/api/invoices/:id/status |
Check invoice status |
Register webhook subscribers in Ledger → Webhooks to receive events:
| Event | Description |
|---|---|
subscription.paid |
Invoice paid via Stripe |
subscription.payment_failed |
Payment attempt failed |
subscription.updated |
Subscription status changed |
subscription.cancelled |
Subscription cancelled |
Payloads are delivered as POST requests with X-Webhook-Secret and X-Webhook-Event headers.
Configure these events in the Stripe Dashboard under Developers → Webhooks:
| Event | Purpose |
|---|---|
checkout.session.completed |
Marks invoice as paid |
payment_intent.payment_failed |
Marks invoice as failed |
customer.subscription.updated |
Updates subscription period/status |
customer.subscription.deleted |
Cancels subscription |
invoice.payment_succeeded |
Handles recurring invoice payments |
Webhook URL: https://your-ledger-url/api/webhooks/stripe
Made and maintained with 🧡 by Headpat