Skip to content

ddtcorex/ledgard

Repository files navigation

Ledgard

Ledgard is a single-ledger family finance web application. It is designed as a self-hosted PWA on Cloudflare with one isolated app/database per family.

Stack

  • Frontend: React 19, Vite, TypeScript, Tailwind CSS, TanStack Query, React Router v7, PWA.
  • Backend: Hono on Cloudflare Workers.
  • Database: Cloudflare D1 (SQLite).
  • Hosting: Cloudflare Workers Assets.
  • Auth: Cloudflare Access in production, DEV_USER_EMAIL fallback only in local development.
  • Local orchestration: Govard.

Main Features

  • Liquidity-first dashboard: available cash first, net worth second.
  • Accounts: cash, bank, credit card, savings.
  • Transaction ledger: income, expense, transfer, loan, debt, debt collection, repayment.
  • Category hierarchy: strict parent -> child.
  • Shared and individual monthly budgets.
  • Recurring budgets: auto-create budgets on monthly, quarterly, or yearly schedules via Worker cron.
  • Budget templates: save current budgets as reusable templates (personal or shared).
  • Period locking through D1 triggers.
  • Scheduled recurring transactions via Worker cron.
  • English and Vietnamese UI (switchable in Settings).
  • Currency setting for common currencies: USD, VND, EUR, GBP, JPY, SGD, AUD, CAD.

Project Layout

migrations/              D1 schema, triggers, seed data
src/client/              React app
src/worker/              Hono Worker API and cron handler
src/shared/              Shared types and finance utilities
tests/                   Unit tests
docs/                    Product specification
wrangler.toml.example     Cloudflare Worker/D1/Assets config template
.dev.vars.example     Local dev environment vars template
.govard.yml           Govard local environment config
.govard/              Govard project overrides

Local Setup

Requirements:

  • Govard installed and configured
  • Docker running

Govard runs two services:

  • web: Vite frontend on port 80
  • worker: Wrangler Worker API on port 8787

The Vite dev server proxies /api to http://worker:8787 through LEDGARD_API_PROXY_TARGET.

Setup Steps

  1. Start shared Govard services:
govard svc up
  1. Install dependencies:
govard tool npm install
  1. Start the project:
govard up
  1. Apply local D1 migrations:
govard env exec worker npm run db:migrate:local
  1. Open the app:
https://ledgard.test/

Health check:

https://ledgard.test/api/health

Common Govard Commands

govard ps                    # Check service status
govard logs -f               # Follow logs
govard restart               # Restart services
govard down                  # Stop services
govard diag --fix            # Fix common issues
govard env cleanup           # Clean up Docker resources

Troubleshooting

If the browser reports an SSL or .test DNS problem:

govard svc up
govard doctor trust

If a port or Docker resource is stuck:

govard diag --fix
govard env cleanup

Authentication Model

There is no app-owned password login.

Local development (Govard):

  • Worker uses DEV_USER_EMAIL from .dev.vars
  • Maps the request to a local member (default: admin@ledgard.local)
  • This fallback is disabled when ENVIRONMENT=production

Production:

  • Cloudflare Access protects the app hostname
  • Cloudflare Access injects CF-Access-Authenticated-User-Email
  • Worker maps that email to members.email
  • If the email is not in members, API returns 403

Database

Local migrations (inside Govard):

govard env exec worker npm run db:migrate:local

Remote migrations (production):

npm run db:migrate:remote

Schema files:

See wrangler.toml.example for the production configuration template.

Period lock:

  • system_settings.global_lock_until_date controls the lock date.
  • D1 triggers block INSERT, UPDATE, and DELETE for transactions dated on or before that date.

Production Deployment On Cloudflare

This app is designed to run as a single Cloudflare Worker with bundled static assets and one D1 database.

Production requirements:

  • Cloudflare account.
  • A Cloudflare-managed domain or subdomain, for example ledger.example.com.
  • Cloudflare Zero Trust enabled for Access authentication.
  • Wrangler login on the machine that deploys.
  • A production admin email that will be allowed by Cloudflare Access and seeded into members.

1. Login To Cloudflare

Authenticate Wrangler:

npx wrangler login

Confirm the active Cloudflare account:

npx wrangler whoami

2. Create Production D1

Create the D1 database:

npx wrangler d1 create ledgard-db

Copy the generated database_id into your wrangler.toml. See wrangler.toml.example for the full configuration template:

[[d1_databases]]
binding = "DB"
database_name = "ledgard-db"
database_id = "YOUR_REAL_DATABASE_ID"

wrangler.toml should NOT be committed. Use wrangler.toml.example as reference.

3. Confirm Production Vars

Production vars in your wrangler.toml should match this template from wrangler.toml.example:

[vars]
ENVIRONMENT = "production"
DEFAULT_LOCALE = "en"
DEFAULT_CURRENCY = "USD"

Do not set DEV_USER_EMAIL in production.

If production needs a different default currency, set DEFAULT_CURRENCY before first deploy or update it later in Settings.

4. Install, Test, And Build

Use npm ci for clean production installs:

npm ci
npm run typecheck
npm run test
npm run build

5. Apply Remote D1 Migrations

Apply schema, triggers, indexes, and seed defaults to the remote D1 database:

npm run db:migrate:remote

Verify the remote tables exist:

npx wrangler d1 execute ledgard-db --remote --command "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name;"

6. Seed The First Admin

Seed the first production admin member. Replace you@example.com and Family Admin:

npx wrangler d1 execute ledgard-db --remote --command "INSERT INTO members (id, name, email, role, is_active) VALUES ('mem-admin-prod', 'Family Admin', 'you@example.com', 'admin', 1) ON CONFLICT(email) DO UPDATE SET name = excluded.name, role = 'admin', is_active = 1;"

For a real family bootstrap, you may keep a private local seed file such as seeds/production_vi.sql and run it from the deploying machine:

npx wrangler d1 execute ledgard-db --remote --file seeds/production_vi.sql

Do not push production seed files to git when they contain real family emails, account names, or private defaults. .gitignore excludes seeds/production*.sql.

Confirm the admin exists:

npx wrangler d1 execute ledgard-db --remote --command "SELECT id, name, email, role, is_active FROM members;"

7. Deploy Worker And Assets

Deploy:

npm run deploy

Wrangler will upload:

  • Worker API from src/worker/index.ts.
  • Static assets from dist/client.
  • Cron trigger from your wrangler.toml.
  • D1 binding named DB.

8. Configure Custom Domain Or Route

In Cloudflare Dashboard, attach the Worker to the production hostname, for example:

ledger.example.com

Recommended path:

  1. Cloudflare Dashboard -> Workers & Pages -> ledgard.
  2. Settings -> Domains & Routes.
  3. Add a custom domain such as ledger.example.com.
  4. Wait until the domain is active.

9. Configure Cloudflare Access

Configure Cloudflare Access after the Worker custom domain exists:

  1. Cloudflare Dashboard -> Zero Trust -> Access -> Applications.
  2. Add a Self-hosted application.
  3. Use the same hostname as the Worker custom domain, for example ledger.example.com.
  4. Add an allow policy for family member emails.
  5. Use OTP/magic-link authentication.
  6. Make sure each allowed email also exists in the members table.

Important: Cloudflare Access must sit in front of the same hostname users open in the browser. The app itself has no password login.

10. Verify Production

Open the health endpoint:

https://ledger.example.com/api/health

Expected response:

{"data":{"ok":true,"service":"ledgard","timestamp":"..."}}

Open the app:

https://ledger.example.com/

Expected behavior:

  • Browser is redirected to Cloudflare Access login if not authenticated.
  • After OTP login, the dashboard loads.
  • If the authenticated email is not present in members, API returns 403.

11. Add More Family Members

After the first admin can access the app, add members through Settings or seed them through D1:

npx wrangler d1 execute ledgard-db --remote --command "INSERT INTO members (id, name, email, role, is_active) VALUES ('mem-member-1', 'Family Member', 'member@example.com', 'member', 1) ON CONFLICT(email) DO UPDATE SET name = excluded.name, role = excluded.role, is_active = 1;"

Also add the same email to the Cloudflare Access allow policy.

12. Production Updates

For normal updates:

git pull
npm ci
npm run typecheck
npm run test
npm run build
npm run db:migrate:remote
npm run deploy

Before destructive schema changes, export or backup D1 first:

npx wrangler d1 export ledgard-db --remote --output ledgard-backup.sql

13. Rollback Notes

Cloudflare Workers keeps deployment versions in the dashboard. If a deploy breaks the app:

  1. Cloudflare Dashboard -> Workers & Pages -> ledgard.
  2. Deployments.
  3. Roll back to the last known-good deployment.
  4. Do not roll back D1 schema without a matching backup plan.

14. Deployment Troubleshooting Notes

Common issues:

Permission errors during deployment:

If npm ci fails with EACCES due to Govard-generated files owned by root:

govard env exec web sh -lc 'chown -R 1000:1000 /app/node_modules /app/.wrangler 2>/dev/null || true'
npm ci

D1 migration issues:

  • D1 remote migrations can fail with incomplete input when trigger bodies use multi-line SELECT CASE ... THEN RAISE(...) END. Use the shorter form:
SELECT RAISE(ABORT, 'MESSAGE')
WHERE condition;
  • D1 remote SQL file execution rejects explicit BEGIN TRANSACTION, COMMIT, and SAVEPOINT statements. Do not wrap seed files in manual transactions.

Authentication issues:

  • The production Worker URL may return 401 Missing Cloudflare Access identity until Cloudflare Access is configured. /api/health remains public for health checks.
  • Local Govard dev uses .dev.vars with ENVIRONMENT=development and DEV_USER_EMAIL. See .dev.vars.example for the template.

Network issues:

  • http://ledgard.test/ redirects to HTTPS. If terminal DNS lookup for .test is slow due to IPv6/AAAA resolution, use IPv4:
curl -4 -k https://ledgard.test/api/health

Recurring Transactions

Cron is configured in wrangler.toml.example:

[triggers]
crons = ["0 0 * * *"]

The scheduled Worker scans active scheduled transactions and inserts due ledger entries.

Miniflare local dev does not automatically trigger scheduled Workers. Test cron behavior through the service function or Wrangler scheduled testing mode when needed.

Recurring Budgets

Ledgard supports recurring budgets that automatically create budgets on a schedule:

  • Monthly: Creates budget every month
  • Quarterly: Creates budget every 3 months (Jan, Apr, Jul, Oct)
  • Yearly: Creates budget once per year

Recurring budgets are processed by the Worker cron handler (runs daily at midnight UTC). The system uses idempotency tracking via the recurring_budget_runs table to prevent duplicate budget creation.

API Endpoints

  • GET /api/recurring-budgets - List all recurring budgets
  • POST /api/recurring-budgets - Create new recurring budget (admin only)
  • PATCH /api/recurring-budgets/:id - Update recurring budget (admin only)
  • DELETE /api/recurring-budgets/:id - Delete recurring budget (admin only)

Budget Templates

Save current month's budgets as a template for reuse:

  • Personal templates - Visible only to creator
  • Shared templates - Visible to all members (admin only)
  • Apply template to any future month
  • Prevents duplicate budgets when applying template multiple times

API Endpoints

  • GET /api/budget-templates - List templates (user's + shared)
  • GET /api/budget-templates/:id/items - Get template items
  • POST /api/budget-templates - Create template (admin only)
  • PATCH /api/budget-templates/:id - Update template (admin only)
  • DELETE /api/budget-templates/:id - Delete template (admin only)
  • POST /api/budget-templates/:id/apply - Apply template to target month (admin only)

Production Maintenance

Gravatar Avatar Migration

After deploying code updates that include Gravatar support, existing members need their avatars migrated. The migration endpoint is admin-only and protected by Cloudflare Access.

To run the migration:

  1. Open your production app in a browser (e.g., https://ledger.example.com)
  2. Log in through Cloudflare Access
  3. Open Developer Tools (F12) → Console tab
  4. Run this code:
fetch('/api/members/migrate-avatars', {
  method: 'POST',
  headers: { 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => console.log('Migration result:', data))
.catch(err => console.error('Error:', err))

Expected response:

{"data":{"updated":2}}

This will generate Gravatar URLs for all members based on their email addresses. New members automatically get Gravatar URLs when created.

Scripts

Development (via Govard):

govard up                                        # Start web and worker services
govard tool npm run typecheck                    # TypeScript check
govard tool npm run test                         # Vitest unit tests
govard tool npm run test:watch                   # Vitest in watch mode
govard tool npm run build                        # Typecheck and build frontend assets
govard env exec worker npm run db:migrate:local  # Apply D1 migrations locally

Production deployment:

npm run db:migrate:remote   # Apply D1 migrations remotely
npm run deploy              # Build and deploy Worker/Assets

Verification

Run before committing or deploying:

govard tool npm run typecheck
govard tool npm run test
govard tool npm run build

Smoke checks:

curl -4 -k https://ledgard.test/api/health
govard ps
govard logs -f

Notes

  • All local development uses Govard for consistent environment setup
  • wrangler.toml.example contains a template; wrangler.toml with real D1 id should NOT be committed
  • Seed data is for development and MVP smoke testing
  • Production seed files such as seeds/production_vi.sql are local/private bootstrap files and should not be pushed to git
  • Currency switching changes the app/base display setting. It does not perform historical FX conversion
  • The MVP debt/loan model uses ledger transaction types only; it does not yet have separate counterparty or obligation tables

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

Development Setup

See Local Setup section above for prerequisites and setup instructions.

Code Quality

Before submitting a PR, ensure:

govard tool npm run typecheck   # TypeScript passes
govard tool npm run test         # All tests pass
govard tool npm run build        # Build succeeds

License

This project is licensed under the MIT License - see the LICENSE file for details.

Packages

 
 
 

Contributors

Languages