A calm, trustworthy, privacy-first personal finance PWA.
HushLedger is a single-user, online-first personal finance tool with interfaces in Traditional Chinese, English, Japanese, and French. Built with the Next.js App Router and deployed through OpenNext to Cloudflare Workers and D1, it focuses on fast transaction entry, clear monthly summaries, and dependable daily, weekly, monthly, and yearly recurring transactions and account transfers. Production deployments must be protected by Cloudflare Access.
Live demo: Try HushLedger in your browser. Use sample data only; this public deployment is not a private ledger.
You do not need to deploy HushLedger to use it. Local mode needs no Cloudflare account, domain, Access setup, or API key. Deploy only when you want the same private ledger available from other devices or outside your computer.
New to deployment? Follow the beginner-friendly Cloudflare guide. It requires no Git knowledge and explains every command and dashboard click.
- Monthly income, expenses, balance, a privacy-aware recorded savings rate when income is available, and recent transactions.
- A centered mobile quick-add action opens the same short transaction form as the desktop header without introducing a separate entry path.
- A zero-filled six-month recorded cash-flow trend comparing income, expenses, and net movement without treating transfers, opening balances, or forecasts as cash flow, with exact, neutral selected-month differences from the previous calendar month, private-screen masking, and one-tap navigation to any month in the chart.
- A ranked monthly income-source breakdown by category, with exact totals, transaction counts, shares of recorded income, and one-tap drilldown into the matching income records. Five sources stay visible by default, while an explicit action reveals every source, including inactive historical categories. Transfers, opening balances, and recurring forecasts never become recorded income, and privacy mode masks amounts, shares, and relative bar lengths.
- A ranked monthly expense breakdown switchable between categories and named payees, with exact totals, transaction counts, and one-tap drilldown into matching ledger records. Category rows compare each recorded total with the same category in the previous calendar month using neutral absolute differences; an in-progress selected month may be incomplete. Five rows stay visible by default, while an explicit action reveals the complete private aggregate. Payee drilldown uses a trimmed, case-insensitive exact match rather than broad text search.
- Optional monthly plans on expense categories, with planned, recorded, and remaining-or-over amounts shown together. A complete roll-up separates total planned spending, recorded spending in planned categories, the net plan difference, and spending outside every plan. Five rows stay visible by default, while an explicit action reveals every plan. Plans are recurring comparison guardrails only: they do not reserve cash, roll balances forward, or move money.
- A rolling 35-Hong-Kong-calendar-day outlook of active recurring entries that have not yet been generated, independent of the selected report month. It shows the absolute inclusive display dates, exact rule dates and amounts, and five consecutive 7-day periods for scheduled income, expenses, and net totals. A separate expandable list shows every ungenerated scheduled transfer with its exact ledger date, amount, and source-to-destination account direction; transfers never enter those totals. These local rule values are not bank confirmation, actual transactions, available balance or runway, or guaranteed dates or amounts.
- Amounts stored in the ledger's selected currency as integer minor units to avoid floating-point errors.
- Per-account recorded, cleared, and uncleared balances at the end of the selected month, plus the exact number of still-uncleared entries through that cutoff so offsetting movements cannot hide review work. An optional dated opening balance anchors incomplete history. The in-app month-end reconciliation workspace compares the statement with the exact cleared balance. It keeps the statement value only on screen and never claims to lock the ledger. Its normal register remains range-bound and capped for responsive browsing; when that view cannot prove completeness, an explicit private action can load every uncleared entry through the statement close, including older out-of-period rows, with direct posting-status controls and exact full-history running balances. The same account register merges ordinary transactions with both transfer legs. Its separate CSV export downloads the complete selected account and range, including clearing state and running balances, without the 200-row screen limit.
- One optional emergency-fund checkpoint compares a user-chosen positive target with the recorded month-end balance of one active cash, bank, or wallet account. It does not create a separate balance, reserve or move money, recommend an amount or deadline, forecast future funds, or verify availability with a provider.
- A six-month recorded net-worth trend across every active and inactive account, including negative debts. Months with unknown pre-opening history are marked unavailable instead of silently omitting an account, and selecting a month opens that month throughout the overview.
- Record money moved between two accounts as one atomic transfer with independent source and destination posting states. Transfers stay outside income, expense, balance, category, plan, trend, and CSV transaction reports, so withdrawals and credit-card payments do not manufacture spending or income.
- Schedule a fixed same-currency transfer between two owned accounts with a daily, weekly, monthly, or yearly cadence and an optional inclusive end date. Each due occurrence inside the displayed 35-day window is visible until it becomes one native transfer with both sides uncleared for review. Earlier ungenerated dates remain part of the rule's due-generation workflow rather than this forward-looking window. This is ledger automation only: HushLedger never contacts a bank, moves real funds, or claims sufficient balance.
- Calculate a transaction amount with
+,-,*,/, or parentheses. A bounded no-eval parser rounds only the final result before storing exact minor units, with touch-friendly operator buttons for mobile entry. - Save and close the transaction dialog from any field with
Ctrl+EnterorCommand+Enter. The existing Save button advertises the shortcut visually and to assistive technology; HushLedger does not capture global single-letter keys. - Closing or reloading a changed transaction, account transfer, or recurring rule requires explicit discard confirmation. Draft values remain only in the open dialog; HushLedger does not persist them to browser storage. If the ledger changes in another tab, the preserved draft keeps its opening currency and cannot be saved until the form is reopened against the new ledger state.
- Stack search, exact amount, income/expense, cleared/uncleared, the three-state import checklist, account, category, and exact possible-duplicate filters across matching transactions. The first 200 rows in the selected order load privately, and an explicit control loads the next 200 without putting filters or cursor data in the URL or browser storage. An invalid amount draft never removes an already-applied exact filter or broadens the result. Review the selected month, the seven Hong Kong calendar dates ending when the page loaded, one-click 12 complete calendar months through the selected month, an inclusive custom date range, or all history without changing the monthly overview, balances, transfers, plans, or forecasts. Both quick reviews become visible fixed inclusive ranges; the 12-month range becomes custom if later month navigation would otherwise move it. Inactive references remain available for historical review, and the loaded count is disclosed explicitly. Duplicate review is read-only and marks candidates instead of deleting either entry.
- Order a transaction review by newest or oldest date, largest or smallest amount, or payee name. The order is strictly validated and also applies to the complete CSV export; blank payees remain last.
- Review the exact match count, income, expense, and net amount for the current transaction filters. These totals cover every match, not only the rows currently loaded in the interactive list.
- Save up to eight named transaction views in the current browser and reapply their selected-month, fixed custom-range, or all-history scope plus type, clearing, import-checklist, account, category, exact payee, exact amount, search, tag, possible-duplicate, and ordering criteria. Selected-month views follow the month navigator; custom ranges keep their exact dates. Views contain review criteria only and do not sync to Cloudflare.
- Mark transactions as cleared when they appear at the bank. Manual, duplicated, and recurring entries begin uncleared for review; bank imports begin cleared, while HushLedger CSV and full-ledger backups preserve their recorded state. The account register can switch a transaction or the displayed side of a transfer directly, even when a complete uncleared review has loaded an older row whose full editor record is outside the current screen data. Loaded editor rows still open the full editor.
- Review every imported transaction with a reversible local checklist state:
unreviewed, needs follow-up, or reviewed. New and upgraded imports start
unreviewed. Transactions created manually or by recurrence have no state unless
a later import links them. This checklist is separate from clearing and
#follow-up, and does not detect or determine fraud, authorization, or bank confirmation. - Explicitly select up to 200 visible ledger rows to mark them cleared or uncleared together, move a same-type selection to another active category, or change the import-checklist state when every selected row is imported. Each change is all-or-none: if any selected row has changed in another session, HushLedger leaves the complete selection untouched. Hidden matches are never included, mixed income/expense selections cannot be recategorized, and bulk delete is deliberately unavailable.
- Add case-sensitive, whitespace-delimited
#tagsto transaction notes. Tag chips apply an exact filter, stack with the other ledger filters, and carry through to the complete CSV export without adding a separate metadata store. The#follow-upshortcut retrieves items you marked for personal review; it does not classify fraud and remains independent of the structured import checklist. - Export every transaction matching the current date scope and filters as an Excel-friendly UTF-8 CSV, without the 200-row display limit and with user-entered spreadsheet formulas neutralized. The checklist filter scopes the exported rows without adding a column or changing the ordinary CSV header.
- Export a separate, oldest-first account-register CSV for the selected inclusive date range. It includes an explicit available range-start balance, transactions, the selected account's signed transfer legs, clearing state, and exact running balances. It is a plaintext reconciliation report, not a transaction-import or full-ledger restore format.
- Re-import HushLedger CSV files directly, or map a bank CSV's delimiter, date, description, signed amount or debit/credit columns, destination account, and fallback categories in the browser. Bank imports can reuse the latest active category for an exact payee and income/expense match, then correct any row's category before a fresh duplicate check. New rows are selected automatically. Every committed import row enters the local checklist as unreviewed, including a uniquely matched uncleared transaction that receives an import source key. A cleared row that uniquely matches one otherwise identical uncleared ledger entry links its source and clears that entry instead of adding a duplicate; ambiguous matches stay unselected. Import tombstones stop the same source row from returning after deletion. Repeated imports can explicitly remember column, date, amount, and sign choices for the exact same headers in this browser without storing file contents, accounts, or categories.
- Download a versioned full-ledger JSON backup from Settings through an explicit
same-origin action; direct
GETnavigation cannot start a plaintext download. Restore first shows a checksum-verified replacement report, then requires an explicit destructive confirmation before one transactional D1 replacement. A restore waits for in-flight ledger changes, then temporarily locks ledger controls, navigation, restarts, and leave-without-warning paths through refresh. A browser-local health record shows the last prepared backup download and integrity check. When that browser has no preparation record from the last 30 days, Overview links to the backup settings without claiming that an off-platform copy is missing or restorable. - Edit or delete an existing transaction with conflict detection if another session changed it first.
- Before a manual create or edit, warn when the local ledger already contains an exact match on type, amount, currency, account, category, date, payee, and note. The check returns only a count, ignores clearing status, never calls AI, and can be overridden because identical real purchases are valid. The same predicate powers a review filter that can be stacked, saved, summarized, and exported without changing either candidate.
- Duplicate an existing transaction into a reviewable draft with a fresh UUID; editable details and date are copied, while recurring/import provenance is not.
- Turn an existing manual transaction into a prefilled monthly recurring-rule draft; the first generation date advances to the next occurrence and nothing is scheduled until the reviewed rule is created.
- Create and rename accounts and categories from Settings, and disable or re-enable them without deleting transaction history. Arrow controls reorder accounts within the same status and categories within the same type/status. Accounts can also store an optional signed opening balance and its effective date; credit-card debt can therefore begin below zero without a fake expense. Active recurring transaction rules protect their account and category, while active scheduled transfers protect both accounts. The last usable references are also protected from accidental disabling.
- Custom payees and notes, with private suggestions that can reuse a known payee's latest still-active account and category without sending ledger data to a third party.
- Daily, weekly, monthly, and yearly recurring transactions that can be created, edited, paused, resumed, skipped once without creating a transaction, and deleted. An optional inclusive end date automatically completes a rule after its final scheduled occurrence without deleting generated history.
- Daily, weekly, monthly, and yearly scheduled account transfers with distinct source/destination accounts, exact amount, note, start date, optional inclusive end date, pause/resume, one-occurrence skip, completion, and preserved generated history. Offline and demo views expose no live mutations and store no rule draft.
- Due-transaction generation through Cloudflare Cron or a manual action, with no duplicate occurrence for the same rule and date. A manual run reports blocked, failed, or safety-limited work as incomplete instead of presenting full success. Both recurring lists use the Hong Kong calendar date to put overdue and due-today active rules first, distinguish the next seven days, and leave paused or completed rules out of the due count. These labels describe ledger entries waiting to be generated; they do not assert that an external bill was paid or move real funds.
- A neutral amount review when the most recent remaining transaction generated by a recurring rule differs from that rule's amount for future entries. HushLedger shows the recorded due date and both locally stored amounts as informational context. A difference alone is not labelled an error, and HushLedger never infers a cause or changes either amount automatically.
- Stable end-of-month anchors: a January 31 rule runs on the last day of February and returns to March 31 instead of drifting.
- Stable leap-day anchors: a yearly February 29 rule runs on February's final day in non-leap years and returns to February 29 in the next leap year.
- A PWA app shell, mobile bottom sheets, and responsive tablet and desktop layouts.
- One silent data recheck when an already-loaded live ledger tab returns from hidden state. Successful responses replace stale money and both recurring-rule views; failed rechecks preserve the current view and never substitute demo data. HushLedger adds no polling timer or background-sync service for this check.
- A one-tap screen privacy mode that masks every formatted amount, category-share bar, editable amount field, pasted AI bank text, and raw mapped-CSV sample for safer screen sharing, and turns on automatically while the app is hidden or unfocused without changing the user's prior choice.
- Manual-by-default app updates with an opt-in automatic install-and-restart mode.
- Clear loading, demo, offline, success, and error states.
- A settings page for switching immediately among Traditional Chinese, English, Japanese, and French, for choosing the default/reporting currency before monetary history exists, and for assigning a native currency to each account. Currency stays private in D1 and full-ledger backups. A user can explicitly fetch and retain ECB EUR-base reference-rate observations; HushLedger never fetches them in the background and does not convert ledger amounts.
HushLedger starts with cash, bank, credit-card, wallet, income, and expense defaults. Settings can add custom accounts and categories, rename them, change a custom or built-in account type, disable or re-enable entries, and persist a preferred order without drag-and-drop. Disabled entries disappear from new transaction choices but remain attached to history; the app intentionally offers no destructive account/category delete action. See PROJECT_BRIEF.md.
In HushLedger, a payment method is an account such as cash, a bank account, a credit card, or a digital wallet. It is not an additional transaction type.
- A fresh ledger defaults to HKD. Each account has an immutable native currency; transactions, imports, recurring rules, opening balances, and same-currency transfers follow it. Settings can choose AED, AUD, CAD, CHF, CNY, CZK, DKK, EUR, GBP, HKD, ILS, INR, MOP, MXN, MYR, NOK, NZD, PHP, PLN, QAR, SAR, SEK, SGD, THB, TRY, TWD, USD, or ZAR; every supported currency uses two decimal minor units in HushLedger.
- The default/reporting currency can change only while the ledger has no transactions, transfers, recurring transaction or transfer rules, import tombstones, opening balances, category plans, or emergency-fund checkpoint. A change relabels that pristine ledger and never converts amounts or applies an exchange rate. Until conversion exists, dashboard totals and net worth include only the reporting currency.
- ECB reference rates are optional, explicitly user-requested public data. They are stored as immutable EUR-base decimal-text observations with their ECB date and local fetch time, are included in backups, and never alter original amounts.
- An amount of 123.45 is stored as
12345inamount_minor. - Named transaction views live only in browser storage. They include the bounded transaction date scope and ordering choice. A custom range stores its exact inclusive endpoints, while selected-month views exclude a particular month. Views never contain transactions; an optional exact amount criterion is stored as a validated minor-unit integer. Legacy saved views default the structured import-checklist filter to all states. Views are validated before reuse and are not included in CSV exports or full-ledger backups.
- Transaction queries default to the selected month.
scope=rangerequires one valid, ordereddateFrom/dateTopair and includes both endpoints;scope=allremoves the date bound. Either wider scope affects only the ordinary transaction list, its filtered aggregate, and CSV export; monthly financial reports and account calculations remain anchored tomonth. - Transactions use client-generated UUIDs, so a safe retry does not create a duplicate transaction.
- Manual duplicate preflight is same-origin, read-only, and exact across type, amount, currency, account, category, date, payee, and note. It excludes the current transaction during editing, deliberately ignores clearing status, and warns rather than enforcing uniqueness. The explicit Duplicate action skips a redundant preflight because the user has already chosen to create another row. The possible-duplicate review filter uses the same fields, shows every member of an exact-match group, and never merges or deletes automatically.
- Duplicating a transaction opens a separate create-mode draft for review. It copies only editable fields, never recurring provenance, audit timestamps, or import identity, resets the draft to uncleared, and requires active account/category references.
- Existing ledgers and schema-8 backups are upgraded as cleared history. New manual and recurring transactions default to uncleared; reviewed bank imports default to cleared. This status records bank-posting review only and is not an irreversible reconciliation lock.
- Existing categories and schema-8/9 backups receive no monthly plan by default. A plan can only be a positive safe-integer amount in the ledger currency on an expense category; it never represents reserved or available cash.
- An account opening balance is the balance immediately before its paired
YYYY-MM-DDdate. The two values are both present or both null. Null means the app derives the balance from all recorded history; schema-8 through schema-11 backups upgrade to that null state rather than inventing a baseline. - Account balances include income, expenses, and both sides of transfers before the selected month-end cutoff. Cleared balances include only posted movements, and the uncleared count treats each transfer side independently; the reconciliation workspace stores no statement value and does not claim an irreversible reconciliation lock. A complete uncleared review is loaded only after an explicit same-origin request, verifies its exact row count, and remains in current-screen memory. Each inline posting-status change is account-bound and concurrency-checked; changing a transfer updates only the selected account's leg.
- The ledger stores at most one emergency-fund checkpoint, backed by one active cash, bank, or wallet account in the ledger currency. Progress uses that account's recorded month-end balance only: negative balances contribute zero, and progress above the target is capped at the target. The checkpoint is not an envelope, reserved or available cash, a recommendation, a deadline, or an automated transfer.
- Recorded net worth is the exact sum of all available signed account balances at each month end. Transfers therefore have zero net effect. If any account balance is unavailable for a month, the complete net-worth point is unavailable too.
- Transaction CSV exports include the transaction UUID for lossless round trips. Older HushLedger exports without that column receive stable row fingerprints during import; identical rows retain separate occurrence keys.
- Generic bank imports hash an optional bank transaction ID with the destination account. Files without IDs receive stable content-and-occurrence fingerprints; raw bank IDs are not stored in the tombstone key.
- A transaction stores only a calendar date in
YYYY-MM-DDformat. There is no transaction time field. created_atandupdated_atare internal UTC audit timestamps, not user-entered transaction times.- Transaction edits preserve recurring-rule provenance and use
updated_atas an optimistic concurrency token; stale updates and deletes are rejected. - Bulk clearing accepts only explicit transaction IDs paired with their
updated_atversions, is bounded to 200 rows, and runs as one guarded SQL statement. A missing or stale row makes the complete update fail without a partial posting-status change. Its route-specific 32 KiB body cap accommodates all 200 canonical UUID/version pairs while remaining independently bounded. - Bulk recategorization uses the same explicit, bounded conflict tokens and a D1
transactional batch. The target must be active and match every selected
transaction type;
RETURNINGverifies the exact rows without counting the ledger-revision trigger as another transaction update. - Account transfers use client-generated UUIDs, require two distinct compatible
accounts, and use
updated_atconflict detection. A transfer is one atomic row, not a pair of income/expense transactions; its two clearing flags can represent money that has left one account but has not yet reached the other. Generated transfers also retain an immutable rule-name, due-date, and occurrence-key snapshot that ordinary transfer edits cannot replace. - Account activity drilldown filters transactions and transfers on the server. The 200-transfer display limit is applied after the account filter, rather than fetching an unrelated global slice and hiding rows in the browser.
- The account register orders dated opening balances, transactions, and incoming or outgoing transfer legs in one stable stream. When a month exceeds 200 rows, it returns the newest 200 but calculates every displayed running balance from the complete month. Its explicit all-uncleared action calculates running balances over all known activity through the cutoff before returning every uncleared row, verifies the exact count, and has no 200-row cap. A separate same-origin CSV action re-reads every matching range entry without that display cap and orders them oldest-first for review. Activity before a dated opening balance is never presented as trustworthy history.
- Disabled accounts and categories are unavailable to new entries. An existing transaction may keep and edit against its archived references until the user explicitly reassigns them to active ones.
- A recurring rule occurrence date is an immutable idempotency key. Editing a rule affects only future occurrences that have not been generated. Pausing or deleting a rule never deletes historical transactions or transfers.
- A recurring rule end date is inclusive: an occurrence may be generated on that date, and the rule is complete only after its generation cursor advances beyond it.
- Every API input is validated with Zod and checked again against server-side account, category, and transaction-type rules.
- Selected CSV rows are committed with a D1 transactional batch. A unique exact match links and clears the existing transaction in that same batch. Possible or ambiguous duplicates require an explicit checkbox; invalid or archived references are never silently substituted.
- Full-ledger backups cover the ledger currency, accounts, categories, the emergency-fund checkpoint, recurring transaction and transfer rules, transactions, account transfers, and import tombstones. A SHA-256 checksum detects modification, and a monotonic ledger revision rejects restore previews that became stale before commit.
Cloudflare Access + custom Worker
│ JWT, CSP, Cron
▼
Next.js 16 App Router via OpenNext
│ Server Actions + Route Handlers
▼
Cloudflare D1
HushLedger does not require a separate database server, a Docker database, third-party fonts, or an application-level login. Cloudflare Access is the production authentication boundary.
| Goal | Command | Database | Cloudflare required? |
|---|---|---|---|
| Use it on this computer | npm run dev |
Local D1 | No |
| Use it without installing Node.js | Docker Desktop or Apple Container | Local D1 volume | No |
| Test the production-style Worker locally | npm run preview |
Local D1 | No |
| Use it privately from multiple devices | npm run deploy |
Cloudflare D1 | Yes: account, domain, and Access |
HushLedger itself does not use an application API key. Its optional AI draft feature uses a provider key that you enter in Settings. You can use it only in the current tab or explicitly persist it as AES-GCM ciphertext in D1. Browser and API responses expose only redacted settings metadata; AI proxy routes decrypt a saved key server-side. Local data stays in Wrangler's ignored local state and is separate from any deployed D1 database. After you attach its protected custom domain, a Cloudflare deployment becomes internet-reachable, but the app must remain private behind Cloudflare Access; it is not intended to be a public ledger.
HushLedger includes the following interface languages:
- Traditional Chinese (
zh-Hant) - English (
en) - Japanese (
ja) - French (
fr)
On first use, the app selects the closest supported language from the browser preferences. The language can be changed at any time from Settings. The selected locale is stored only in browser local storage; it is not written to D1, sent to the Worker, or used to create a user profile.
Dates, months, amounts, navigation, forms, status messages, and error messages use the selected language. User-defined account names, category names, payees, notes, and recurring-rule names are always preserved exactly as entered.
The eye button in the header and the matching Settings control enable screen privacy mode for the current tab. It replaces displayed amounts with a stable currency mask, removes spending-breakdown proportions, masks editable amount fields and exact-amount filter descriptions, and covers pasted bank text without hover-to-reveal behavior. It turns on automatically when the tab is hidden or the app loses focus, then restores the user's prior choice on return. Reloading clears the manual choice. This is a visual screen- sharing aid only: it does not encrypt, delete, or alter D1 data, API responses, CSV exports, browser memory, or text already sent to a configured AI provider.
App updates are manual by default. Settings can check for a new version and install it when you are ready. Automatic mode applies a detected version immediately and restarts the app, so unsaved form input can be lost. This preference is stored only in browser local storage. The updater refreshes the web app served by the current deployment; it does not pull or replace a Docker or Apple Container image.
Settings can download one versioned JSON file containing the ledger currency and every account, category, optional emergency-fund checkpoint, recurring transaction or transfer rule (including soft-deleted rule history), transaction, account transfer, and import tombstone, plus any explicitly fetched ECB reference-rate observations. AI provider settings, pasted bank text, language preferences, update preferences, saved transaction views, remembered bank CSV layouts, and screen privacy state are intentionally excluded.
This app-level backup exclusion does not remove saved AI settings from the D1 database. A database export or Time Travel history can retain the settings row, with the API key represented as ciphertext rather than plaintext.
The JSON file is plaintext financial data. Store it only in encrypted storage and do not commit, email, or attach it to an issue. Its SHA-256 checksum detects damage or modification; it is an integrity check, not encryption or authentication.
Settings records, in this browser only, when HushLedger last prepared a backup download and successfully validated a backup. It warns after 30 days without a download. These dates are a maintenance reminder, not proof that the file was retained or stored safely.
Restore is preview-first:
- Choose a HushLedger JSON backup of at most 7 MiB.
- HushLedger validates the format and schema version, checksum, unique keys, account/category references, recurring provenance, and active reference minimums.
- Review the current-versus-backup row counts for all nine collections.
- Download a fresh backup, then type
RESTOREto enable the destructive action. - HushLedger rechecks the live ledger revision and replaces the currency and all nine ledger collections in one D1 transaction. A stale preview or any constraint failure writes nothing.
After a successful replacement, HushLedger removes saved transaction views from that browser and synchronizes the removal to other open tabs, so payee, search, tag, account, and category filters from the previous ledger cannot be applied to unrelated restored data. Other browser-only preferences remain unchanged. If the browser refuses the cleanup, the committed restore remains intact and HushLedger warns the user to clear the site's browser data before reloading or reusing saved views.
The in-app format is for practical personal-ledger portability. The running build writes schema 20 and accepts schemas 8 through 20. Schema 8 through schema 19 backups upgrade in memory; schema 8 through schema 13 default the ledger currency to HKD, while schema 8 through schema 12 also upgrade without inventing an emergency-fund checkpoint. Their existing version-specific defaults for clearing state, monthly plans, transfers, and opening balances still apply. Schema-14 through schema-20 restores carry their currency with the rest of the ledger instead of converting any amount. Only schemas 15–20 can contain yearly recurring transaction rules, only schemas 16–20 can store their end dates, and only schemas 17–20 store scheduled-transfer rules and generated-transfer provenance. Schemas 18–20 preserve the structured import-review state; older backups reconstruct imported rows from their surviving import keys as unreviewed without assigning a state to manual rows. Schema 20 also preserves optional ECB reference-rate observations; older backups upgrade with an empty observation list and never invent rates. For a backup larger than 7 MiB, long-term disaster recovery, or a database-level archive, keep Wrangler D1 exports in encrypted off-platform storage and follow the restore and recovery process in the advanced Cloudflare guide.
This is also the supported way to use HushLedger on one computer. Requirements: Node.js 22+ and npm 10+.
git clone https://github.com/Coke1120/HushLedger.git
cd HushLedger
npm ci
npm run db:localStart Next.js. OpenNext's development bridge supplies the local D1 binding from
wrangler.jsonc, so only one process is needed:
npm run devOpen http://localhost:3000. Demo data remains only in the current page session
when live data is unavailable. Mutations are blocked while offline; the app never
pretends that an offline change was synchronized.
npm run dev deliberately retires any HushLedger service worker on that origin
and removes only HushLedger or legacy Workbox app-shell caches. Next.js development
chunk URLs can remain stable across edits, so keeping the production cache-first
worker would risk loading old application code after a restart. This cleanup does
not touch D1, local storage, IndexedDB, or ledger data. Use npm run preview when
testing the production offline shell and in-app update workflow.
Local mode has no application sign-in. The project script binds the server to
127.0.0.1 only; protect your operating-system account and disk, and treat
.wrangler/ as private financial data even though Git ignores it. On macOS and
Linux, the supported dev, preview, and db:local scripts repair its POSIX
modes to 0700 directories and 0600 files before use. They reject links that
could redirect the repair outside the state tree, and macOS ACLs are removed. An
inherited 0077 umask removes group/other mode access from subsequently created
entries. Direct Next or Wrangler commands bypass this protection. Windows folder
ACLs are not changed; keep the checkout inside your private user profile and
protect the disk.
The AI draft feature also works in local mode; Cloudflare deployment is not
required. Enter the provider base URL, key, and model in Settings. Public HTTPS
providers work locally. Under npm run dev, you may also use a loopback provider
such as http://127.0.0.1:<port>/v1. A deployed Worker—and the production-style
npm run preview runtime—cannot reach a provider running on your computer's
localhost.
Memory-only AI use needs no encryption secret. To persist provider settings in
the local D1 database, add one freshly generated 64-character hexadecimal value
to the Git-ignored .dev.vars file:
AI_SETTINGS_ENCRYPTION_KEY_V1=<output of openssl rand -hex 32>Run openssl rand -hex 32 locally, paste its output after =, and never commit
the file or value. Replacing or losing this key makes existing saved credentials
undecryptable; enter and save the provider key again after an intentional change.
npm run start is intentionally not defined. A plain Next.js production server
does not provide HushLedger's Cloudflare D1 binding. Use npm run dev for normal
local use or npm run preview for the production-style OpenNext Worker running
locally.
For a production-like workerd preview using the generated OpenNext bundle:
npm run previewThen open http://localhost:8787.
The optional container image runs the same OpenNext Worker and local D1 runtime
without installing Node.js on the host. It is a private local deployment, not a
public server. Both Docker Desktop and Apple Container build the same
OCI-compatible Dockerfile.
The build context excludes local .env* and Wrangler .dev.vars* secrets.
Pending D1 migrations are applied automatically whenever the container starts.
Keep port 8787 bound to 127.0.0.1. Local mode has no application login, so do
not expose this container to a LAN or the public internet. Treat its data volume
as private financial data. Container startup removes group/other POSIX mode access
from /data before migrations run.
Build the image, then create the app and its persistent named volume:
docker build --tag hushledger:local .
docker run --name hushledger --detach \
--restart unless-stopped \
--publish 127.0.0.1:8787:8787 \
--volume hushledger-data:/data \
hushledger:localOpen http://127.0.0.1:8787. Use docker stop hushledger and
docker start hushledger without losing data.
Apple Container requires Apple silicon and macOS 26. Start its service, build the same image, create the volume once, and run HushLedger:
container system start
container build --tag hushledger:local --file Dockerfile .
container volume create hushledger-data
container run --name hushledger --detach \
--publish 127.0.0.1:8787:8787 \
--volume hushledger-data:/data \
hushledger:localOpen http://127.0.0.1:8787. Use container stop hushledger and
container start hushledger without losing data. Deleting hushledger-data
permanently deletes the local ledger in either runtime.
Keep the container bound to 127.0.0.1; do not change the publish address to
0.0.0.0. On the other computer, open an SSH tunnel through the tailnet to a
host with SSH enabled:
ssh -N -L 8787:127.0.0.1:8787 user@hushledger-hostReplace user@hushledger-host with the Docker host's SSH user and Tailscale
MagicDNS name or IP, keep the tunnel running, then open
http://127.0.0.1:8787 on that computer. Local mode has no application login,
so restrict SSH access with the tailnet policy and host permissions.
Do not use Tailscale Funnel. A direct Tailscale Serve URL is not currently supported because HushLedger's local-access bypass accepts only loopback hostnames; use the SSH tunnel or the private Cloudflare deployment path.
Public HTTPS AI providers work from the container. A provider running on the
host's localhost is intentionally not part of this local-container path.
To upgrade the containerized app, rebuild or pull the new image and replace the
container while keeping hushledger-data; the in-app updater cannot upgrade the
OCI image itself.
npm run db:local
npm run verify
npm audit --omit=dev --audit-level=highnpm run verify runs the Node-based TypeScript unit suite, TypeScript typechecking,
the generated Worker binding-type freshness check, ESLint, Oxlint, the Next.js and
OpenNext production builds, and a workerd
integration gate against isolated temporary D1 databases. The gate rebuilds fresh
and upgraded migration paths and verifies the
App Router shell, privacy-safe PWA assets, security headers, API contracts,
configured Cron schedule, reference-data lifecycle and safety guards,
recurring-rule CRUD, race-safe idempotency, and history preservation. It proves
that transfers leave total net worth unchanged, incomplete opening-balance
history is exposed, and neither filtered transaction CSV nor account-register
CSV export is truncated by the interactive 200-row page size. It
also starts local Next.js with a fake
OpenAI-compatible provider, verifies model discovery and a successful strict
draft parse, proves that parsing creates no D1 transaction, then verifies an
explicit preview/commit, stable re-analysis identity, and a deleted-import
tombstone.
The same gate exports and restores a complete schema-20 JSON ledger, including its
currency and nine data collections, and verifies schema-8 through schema-19
compatibility. Pre-schema-14 backups upgrade to HKD.
Schema-14 and older backups retain their existing daily, weekly, or monthly recurring
rules without inventing a yearly frequency, and schema-15 and older rules receive no
invented end date. Schema-12 and older backups
never invent an emergency-fund checkpoint; schema-11 and older backups also
receive no invented opening balance, schema-10 and older backups receive no
invented transfer, schema-9 and older backups receive no invented category plan,
and schema-8 history upgrades as cleared. Schema-17 and older backups reconstruct
imported rows as unreviewed from surviving import keys. The gate rejects a
modified checksum and a stale preview, and proves that the final re-export exactly
matches the pre-restore data.
Regenerate Worker binding types after changing bindings:
npm run types:worker| Migration | Purpose |
|---|---|
0001_schema.sql |
Creates the initial tables. |
0002_seed.sql |
Adds the initial seed data. |
0003_phase1_hardening.sql |
Adds constraints, indexes, UUID transactions, and the complete seed set. |
0004_transaction_date_only.sql |
Converts legacy transaction timestamps to calendar dates and removes the time field. |
0005_recurring_rules.sql |
Adds recurring rules, the generation cursor, and transaction provenance. |
0006_reference_localization_keys.sql |
Adds stable localization keys for built-in accounts and categories while preserving custom names verbatim. |
0007_transaction_import_keys.sql |
Adds source-key tombstones for duplicate-safe CSV and reviewed AI imports; keys deliberately survive transaction deletion. |
0008_ledger_revision.sql |
Adds a monotonic ledger revision maintained by table triggers so restore previews cannot overwrite newer writes. |
0009_transaction_clearing_status.sql |
Adds cleared/uncleared bank-posting status, preserving existing history as cleared. |
0010_category_monthly_plans.sql |
Adds optional positive monthly spending plans to expense categories without implying reserved cash or rollover. |
0011_account_transfers.sql |
Adds atomic account-to-account transfers with independent source and destination posting states and backup revision triggers. |
0012_account_opening_balances.sql |
Adds optional signed, dated account opening balances with database guards requiring the amount and date together. |
0013_emergency_fund_goal.sql |
Adds one optional account-backed emergency-fund checkpoint and ledger-revision triggers without reserving or moving money. |
0014_ledger_currency.sql |
Adds one ledger-wide two-decimal currency setting, migrates existing ledgers as HKD, cascades pristine currency changes across dependent rows, and blocks relabeling after monetary history exists. |
0015_yearly_recurring_rules.sql |
Allows yearly recurring rules while preserving existing schedules and generated-transaction provenance. |
0016_recurring_rule_end_dates.sql |
Adds optional inclusive end dates so recurring rules can complete automatically without deleting generated history. |
0017_recurring_transfer_rules.sql |
Adds scheduled account-transfer rules plus immutable generated-transfer provenance while preserving manual transfers and report neutrality. |
0018_import_review_status.sql |
Adds a nullable three-state local checklist for imported transactions, backfilling rows with surviving import keys as unreviewed while leaving manual rows unchanged. |
0019_multi_currency_accounts.sql |
Preserves native currencies per account and dependent monetary rows, while keeping the ledger currency as a reporting currency until explicit conversion is added. |
0020_ecb_reference_rates.sql |
Adds immutable, explicitly fetched EUR-base ECB reference-rate observations without modifying ledger money. |
0021_ai_provider_settings.sql |
Adds single-row AI provider settings with an AES-GCM ciphertext, IV, and encryption-key version; app ledger backups deliberately exclude this row. |
Apply migrations locally:
npm run db:localRemote migrations modify production data. Confirm the Cloudflare account,
database, and backups before following the
Cloudflare deployment guide.
Apply 0021_ai_provider_settings.sql before deploying code that persists AI
settings.
Successful responses use { "ok": true, "data": ... }. Error responses use
{ "ok": false, "error": { "code", "message" } }.
GET /api/health
GET /api/ledger-settings (ledger currency and whether it can still change)
PUT /api/ledger-settings (conflict-safe pristine-ledger currency change; no conversion)
GET /api/exchange-rates/ecb (locally retained ECB EUR-base reference-rate observations)
POST /api/exchange-rates/ecb (explicitly fetch the fixed ECB CSV source; never automatic)
GET /api/accounts
GET /api/accounts/balances?month=YYYY-MM (month-end balances and exact uncleared-entry counts)
GET /api/accounts/register?month=YYYY-MM&accountId=ID (merged monthly activity with exact running balances)
POST /api/accounts/register/uncleared (explicit uncapped uncleared snapshot through a cutoff; JSON body)
PATCH /api/accounts/register/clearing (account-bound optimistic status update for one transaction or transfer leg)
GET /api/reports/net-worth?month=YYYY-MM (six complete-or-unavailable month-end net-worth points)
GET /api/emergency-fund-goal (the optional account-backed checkpoint, or null)
PUT /api/emergency-fund-goal (create or conflict-safe update)
DELETE /api/emergency-fund-goal (conflict-safe removal)
POST /api/accounts
PATCH /api/accounts (reorder one complete active/inactive group)
GET /api/accounts/:id
PUT /api/accounts/:id
PATCH /api/accounts/:id
GET /api/categories
POST /api/categories
PATCH /api/categories (reorder one complete type/status group)
GET /api/categories/:id
PUT /api/categories/:id
PATCH /api/categories/:id
GET /api/payee-suggestions (latest references for up to 100 known payees)
POST /api/transactions/query (primary private query; JSON filters in the body; returns capped rows plus the uncapped summary)
GET /api/transactions?... (legacy compatibility; URL query filters, latest 200 rows)
GET /api/transactions/summary?... (legacy compatibility; URL query filters, uncapped aggregate)
POST /api/transactions
POST /api/transactions/duplicates (exact local-ledger match count; no transaction contents)
PATCH /api/transactions/category (atomic category update for 1-200 same-type, explicitly versioned rows)
PATCH /api/transactions/clearing (atomic cleared/uncleared update for 1-200 explicitly versioned rows)
PATCH /api/transactions/import-review (atomic checklist update for 1-200 explicitly versioned imported rows)
GET /api/transactions/:id
PUT /api/transactions/:id
DELETE /api/transactions/:id
GET /api/transfers?month=YYYY-MM[&accountId=ID] (latest 200 matching account transfers)
POST /api/transfers
GET /api/transfers/:id
PUT /api/transfers/:id
DELETE /api/transfers/:id
POST /api/exports/transactions (primary private CSV export; JSON filters in the body)
POST /api/exports/account-register (complete account/range reconciliation CSV; JSON query in the body)
POST /api/backups/ledger (`export`: same-origin versioned full-ledger JSON attachment)
POST /api/backups/ledger (`preview` or `commit`: preview or explicitly confirmed transactional restore)
GET /api/summary?month=YYYY-MM (monthly totals and reports plus a current Hong Kong-date 35-day recurring outlook; includes legacy monthly recurrence fields for cached clients)
GET /api/recurring-rules
GET /api/recurring-rules/:id
POST /api/recurring-rules
PUT /api/recurring-rules/:id
PATCH /api/recurring-rules/:id/status
POST /api/recurring-rules/:id/skip
DELETE /api/recurring-rules/:id
POST /api/recurring-rules/run-due
GET /api/recurring-transfer-rules
GET /api/recurring-transfer-rules/:id
POST /api/recurring-transfer-rules
PUT /api/recurring-transfer-rules/:id
PATCH /api/recurring-transfer-rules/:id/status
POST /api/recurring-transfer-rules/:id/skip
DELETE /api/recurring-transfer-rules/:id
POST /api/recurring-transfer-rules/run-due
GET /api/ai-settings (redacted saved-provider metadata only; never returns key material)
PUT /api/ai-settings (conflict-safe encrypted persistence; requires the Worker encryption secret)
DELETE /api/ai-settings (removes saved settings; does not revoke the upstream provider key)
POST /api/ai/models
POST /api/imports/parse (create untrusted AI drafts; never writes D1)
POST /api/imports/ai (preview or commit reviewed AI drafts, maximum 200 rows)
POST /api/imports/csv (preview or commit normalized HushLedger/bank CSV rows, maximum 200)
Item-level account/category PATCH changes isActive; collection-level PATCH
reorders one complete account status group or category type/status group. PUT
renames an entry and may also change an account type or its paired opening
balance/date. Mutations use updatedAt
for optimistic concurrency. Reordering normalizes positions in one guarded SQL
statement, so a stale or partial list writes nothing. There is no account/category
DELETE: disabling preserves historical foreign-key links, and the server rejects
disabling the last active choice, a choice used by an active recurring transaction
rule, or either account used by an active scheduled transfer.
Transactions default to newest-first and accept only the documented date,
amount, or payee ordering values. A private query response contains at most 200
transactions and an optional continuation cursor held only in current React
memory. The cursor is bound to the parsed filters, exact ordering tuple, and the
trigger-maintained D1 ledger revision; a changed ledger rejects continuation and
refreshes page one instead of mixing snapshots. The UI states exactly how many
rows are loaded out of the complete result and loads another page only after an
explicit action. The cursor is a private-UI consistency guard, not an authorization
token; Cloudflare Access and the same-origin boundary remain authoritative. The
first-page filtered summary uses a separate order-independent
aggregate query over every match, so its count, income, expense, and signed net
are not truncated by the page size. Monthly and
filtered reports fail closed if otherwise-valid entries would combine beyond
JavaScript's exact safe-integer range; HushLedger never returns a rounded money
total as if it were exact. The optional status filter is shared by the aggregate
and uncapped CSV export.
Transfers are a separate ledger surface because movement between owned accounts
must not affect income or expense reporting. New transfers require two active
accounts in the ledger currency. Editing preserves an existing archived source or
destination, uses the same optimistic updatedAt guard as transactions, and
records source and destination posting independently. Transfer routes are strict,
same-origin, and never included in transaction CSV exports. Scheduled generation
uses the same native transfer row, leaves both posting states uncleared, preserves
its rule/date provenance, and remains outside income and expense reporting.
Account balance aggregation is read-only and uses the end of the requested month as an exclusive cutoff. It includes transfers in each account but never feeds them back into income, expense, category, plan, trend, or CSV totals. A statement value entered in the UI stays in component memory only and is compared with the cleared balance; it is not sent to an API or written to D1.
The normal account-register query is read-only. Its starting balance uses the dated opening balance plus every earlier recorded movement inside that account's known history. A dated opening inside the selected range appears as the first trusted entry; ranges entirely before that date remain unavailable. The endpoint merges both sides of transfers without converting them into income or expense and uses stable date, creation-time, and ID ordering for same-day entries.
The complete uncleared review is a separate explicit same-origin JSON POST, not
an automatic widening of the normal range. One database statement calculates
running balances over all known activity through the requested close date before
filtering uncleared rows, returns an exact count with no interactive cap, and fails
closed if the count or money invariants disagree. The response is private and
no-store; the browser keeps it only in the open reconciliation screen and clears
it when the account, dates, mode, or screen changes. Its narrow clearing mutation
requires the account, source ID, kind, and optimistic updatedAt version. For a
transfer it writes only the displayed account's clearing flag, never the opposite
leg or unrelated editable fields.
The account-register export uses the same calculation but removes only the
interactive 200-entry cap. It adds an explicit range_start balance row when the
range has a trustworthy starting balance, then writes every ledger entry oldest-
first with exact signed decimal movement and running balance. Transfer rows contain
only the selected account's leg and retain that leg's clearing state. The route is
same-origin JSON POST only, names files with a numeric account ID and exact range,
and returns formula-neutralized plaintext CSV; screen privacy does not mask or
encrypt the downloaded values. It is deliberately not accepted by transaction CSV
import, and complete restore remains the versioned JSON ledger backup.
Payee suggestions are derived on demand from existing transactions and are never sent to an AI provider or another service. Suggestions are separated by income and expense, ordered by recent use, and capped at 100. A new transaction only reuses the latest account or category when that reference is still active; the user can always replace either choice before saving. Generic bank imports can optionally apply the same exact-match category memory while keeping the selected destination account. No payee rule or additional tracking table is created.
The transaction export route returns a downloadable UTF-8 CSV rather than the
JSON success envelope. It applies the same month, filters, and ordering, is
not restricted to 200 rows, and appends Transaction ID plus Cleared for
deterministic round trips. Export requires an explicit same-origin JSON POST,
keeps financial filters out of the URL, and leaves GET unavailable so a
cross-site top-level navigation cannot trigger a plaintext download. Import
parses the file in the browser. HushLedger exports open
directly; other headered UTF-8 bank CSVs offer comma, semicolon, or tab delimiters,
five numeric date formats, one signed amount or separate debit/credit columns,
optional sign reversal, an optional source ID, explicit account/category defaults,
and optional exact payee-category reuse. Each mapped row's category remains
editable until a fresh duplicate/reference preview; changing it clears the old
selection while preserving the bank source identity. The server receives normalized
rows only and writes only explicitly selected rows. It
matches only a cleared import with one unique, field-identical uncleared entry;
the imported source key is attached to that existing transaction before it is
cleared, while zero or multiple candidates remain possible duplicates.
Users can explicitly remember up to eight exact-header CSV layouts in browser
storage. A layout contains only column indexes, date format, amount mode, sign,
and the payee-category reuse preference; it excludes filenames, transaction rows,
accounts, and categories. Unchecking the option before preview forgets that layout.
This convenience round trip is not a complete database backup. Use the Settings
JSON backup for app-level full-ledger portability, and the encrypted D1 export
and restore process in the advanced Cloudflare guide
for database-level disaster recovery.
The ledger backup POST export mode is the other successful response without the
normal JSON envelope. It requires an explicit same-origin JSON action; GET is
unavailable so a cross-site top-level navigation cannot trigger a plaintext file
download. Restore modes use the normal envelope, a separate 8 MiB request ceiling,
strict same-origin validation, a dry-run report, checksum verification, typed
RESTORE confirmation, and a revision guard inside the D1 transaction.
The UI performs mutations through typed, Zod-validated Server Actions. Compatibility mutation routes remain available and require a same-origin request, a JSON content type, a body no larger than 16 KiB, and a payload accepted by the strict schema. The CSV and reviewed-AI import routes each have a separate 256 KiB validated request ceiling for up to 200 parsed rows. These checks complement the custom Worker's cryptographic Cloudflare Access JWT validation.
Deployment is optional. For private use on the same computer, follow Local development and stop before this section. No HushLedger or Cloudflare API key is needed locally. A separate AI provider key is needed only if you choose to use AI drafts.
Start with the beginner-friendly deployment guide if you want a safe, copy-and-paste walkthrough with no Git experience required.
The advanced Cloudflare guide provides the complete setup, including:
- Wrangler login, D1 creation, and Worker bindings.
- Local and remote migrations.
- Worker deployment and the configured daily Cron schedule.
- Cloudflare Access protection for the custom hostname and every application path, with alternate Worker URLs disabled.
- Unauthorized and authorized browser verification.
- Encrypted external backups and restore drills.
- The AI-settings encryption Worker secret, networking, and privacy guidance.
Do not enter real financial data until Cloudflare Access protects the custom
hostname and every path, including /api/*, while workers.dev and Preview URLs
remain disabled.
The Transactions view can turn pasted plain-text online banking records into editable drafts through a user-provided OpenAI-compatible provider:
- Open Settings and enter the provider base URL, API key, and model ID. Keep the
values in this tab or explicitly save them. “Load models” tests
GET {baseUrl}/models; manual model entry remains available. - Open Transactions, select AI drafts, choose the target account and date order, then paste at most 64 KiB of text.
- Review every returned field. Parsing never writes a transaction or raw statement to D1.
- HushLedger checks the edited rows against the live ledger. New rows and unique exact matches to one uncleared entry are selected by default; possible or ambiguous duplicates require an explicit selection. Select Save selected transactions to commit the reviewed set atomically.
The provider must support Chat Completions and strict json_schema structured
output. Browser code calls only same-origin HushLedger routes; the server appends
fixed /models and /chat/completions paths and forwards the provider request.
Enter only a provider URL you trust; local public hostnames are not DNS-pinned.
Transient settings, pasted text, and unsaved drafts remain in current-tab memory
and disappear on reload. Explicitly saved settings persist the base URL and model
in D1 and the API key only as AES-GCM ciphertext encrypted with the independent
operator-managed Worker secret. Browser and settings API responses return only
redacted metadata; the AI proxy routes load and decrypt the saved key server-side.
Neither mode stores the plaintext key in browser storage, cookies, service-worker
caches, or logs. The pasted text is sent to the provider only after you select
Analyze. Saving reviewed transactions sends only validated, edited fields to
HushLedger; D1 retains a one-way source key and transaction ID so re-analysis,
including after deletion, does not silently restore the same source row.
The full-ledger JSON backup excludes AI settings. D1 exports and Time Travel can retain the ciphertext row. Deleting saved settings does not revoke the upstream credential; rotate or revoke it with the provider when immediate invalidation is required.
OpenAI references: API key safety, Structured Outputs, and Chat Completions.
See AI_BANK_IMPORT_PLAN.md for the implemented security, duplicate-review, idempotency, and atomic-commit boundary.
- Never commit
.dev.vars*,.env*,.wrangler/, local SQLite files, exports, backups, API keys, or real financial data. - Treat downloaded JSON backups as plaintext financial data even when their SHA-256 integrity check passes; keep them in encrypted storage.
- Never record complete amounts, payees, notes, bank records, account identifiers, or request bodies in logs, screenshots, issues, or pull requests.
- Never persist an AI provider key in browser storage. Use transient tab memory or the encrypted D1 settings flow; rotate or revoke the upstream key after exposure.
- Report vulnerabilities privately according to SECURITY.md.
Issues and pull requests are welcome. Read CONTRIBUTING.md and CODE_OF_CONDUCT.md before starting, and use only fictional or thoroughly anonymized test data.
If HushLedger is useful to you, support continued development through GitHub Sponsors or Buy Me a Coffee.
MIT (c) 2026 Coke1120

