1
0
Fork 0
forked from aiolabs/libra

Compare commits

..

50 commits

Author SHA1 Message Date
4b01428a17 Merge pull request 'Wire admin add-account endpoint into the UI' (#46) from feat/add-account-ui into main
Reviewed-on: aiolabs/libra#46
2026-06-18 10:03:10 +00:00
3adb3d356a fix(accounts): match Beancount's DATE grammar in duplicate detection (libra-#48)
_open_directive_exists hardcoded '^YYYY-MM-DD open ' (dash-only, 2-digit,
single-space), but Beancount's DATE token (parser/lexer.l) is
(17|18|19|20)[0-9]{2}[-/][0-9]+[-/][0-9]+ and inter-token whitespace is any
[ \t\r] run. So a validly-formatted existing Open written as '2024/3/5 open X'
or '2020-01-01  open  X' escaped detection → duplicate Open appended →
bean-check rejects the file. Anchor on Beancount's actual date pattern and
[ \t]+ separators. Adds parametrized coverage for slash/single-digit/multi-
space/tab variants.

Found in a coherence pass over the Beancount source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:27:18 +02:00
39440b75a7 fix(accounts): recover ledger-only account instead of blanket 409 (libra-#50)
When add_account reported the Open already existed, the endpoint raised
409 before the DB-mirror step — so an account present in the ledger but
missing from libra's DB (a prior sync failure with no cross-DB atomicity,
or an out-of-band open) was stranded: invisible to permissions with no
recovery path. Now 409 only when the account is already in the DB too;
otherwise sync it and return success. Adds a recovery test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:13:43 +02:00
26eb9d4579 fix(accounts): don't currency-constrain per-user account opens (libra-#49)
get_or_create_user_account opened per-user receivable/payable accounts
constrained to EUR/SATS/USD, so a posting in any other currency tripped
'Invalid currency CAD/GBP/JPY for account Assets:Receivable:User-…' at
bean-check — the exact errors the optional-currencies work set out to fix,
which had only reached the admin chart-account path. Open user accounts
unconstrained (currencies=None) so they hold arbitrary fiat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:06:57 +02:00
0ea96cd384 fix(accounts): anchor duplicate-account detection to a real Open directive (libra-#48)
The existence check matched 'open <name>' anywhere in the chart source,
so a prior account's description metadata or a comment mentioning the
name produced a false 409, while a real directive with an inline comment
and no space ('open X;legacy') was missed → a duplicate Open was appended
and bean-check then rejected the file, breaking every later /api/source
write. Extract the check into a pure _open_directive_exists() anchored to
'^YYYY-MM-DD open <name>' with an account-boundary negative-lookahead, and
unit-test both failure directions plus prefix/child non-matches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:06:28 +02:00
89f0f8ac3a test(accounts): cover admin add-account endpoint
10 integration tests for POST /api/v1/admin/accounts: unconstrained Open
write + escaped description metadata, explicit-currency path, duplicate->409,
invalid-prefix->400, invalid-characters->400 (parametrized), super-user-only
->403. Adds the add_chart_account helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:25:27 +02:00
87a45ee4d5 test(harness): split-layout ledger + disable rate limiter
The test harness was never updated to the post-server-deploy#4 split ledger
layout, so libra's per-user account opens (routed to accounts/users.beancount
by fava_client._infer_target_file) 500'd as a 'non-source file' and fell back
to DB-only — breaking the balance test and contributing to settlement errors.
Make the harness ledger a faithful split (root includes accounts/chart.beancount
+ accounts/users.beancount; title stays in root so the slug still matches).

Also raise lnbits_rate_limit_no for the session: the full suite fires >200
req/min and the default limiter 429'd fixture setup intermittently (10-11
errors). The limiter is built once at app creation, so setting it in the
session settings fixture (before the app fixture) disables it suite-wide.

Net: full suite goes from 1 failed / ~10 errors to fully green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:25:27 +02:00
cd5a6edb7d feat(accounts): validate account-name characters server-side
The endpoint only checked the root prefix, so a direct API call (bypassing
the UI) could write a malformed Open directive into the ledger source.
Add _validate_account_name mirroring Beancount's core/account.py grammar
(root [\p{Lu}][\p{L}\p{Nd}-]*, sub [\p{Lu}\p{Nd}][\p{L}\p{Nd}-]*, >=1
sub-account) — verified to match beancount.core.account.is_valid across
20 cases incl. Unicode, digit-start subs, hyphens. Align the client
segment regex to the same rule (was ASCII-only, rejected valid names).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:55:45 +02:00
051c9f0c22 feat(ui): constrain add-account to a root-type dropdown + sub-path
Free-typing the full hierarchical name let admins fat-finger the parent
(wrong/invalid root). Replace the single name field with a required
Account Type select (the 5 valid roots, mirroring _VALID_ACCOUNT_PREFIXES)
plus a sub-account input, a live 'Will create: ...' preview, and
per-segment validation (each part must be a capitalized Beancount
account component). The root prefix is now structurally guaranteed valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:15:06 +02:00
caef3cf5e8 fix(accounts): 409 when admin-adding an account that already exists
add_account no-ops if the Open directive is already present but returned
a normal-looking dict, so the admin endpoint reported success ('created
(sync pending)') for a duplicate. Return an already_existed flag and
raise 409 from the endpoint. Also anchor the existence check on the Open
directive with a trailing-boundary match so a prefix (Expenses:Gas)
doesn't match a longer sibling (Expenses:GasStation). The flag is
additive, so the idempotent user-account path keeps no-opping silently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:07:39 +02:00
7456574f65 fix(accounts): default CreateChartAccount.currencies to None
The UI omits currencies so the Open directive is written unconstrained,
but the model defaulted currencies to ["EUR","SATS","USD"], so Pydantic
refilled them and the endpoint passed the constraint through — every
admin-created account got a currency-constrained Open (which would
reject postings in other currencies, the same CAD/GBP/JPY bean-check
class we hit on user accounts). Default to None so omission reaches
add_account and the directive is unconstrained; an explicit list still
works for API callers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:53:04 +02:00
9dd46e818c feat(ui): wire admin add-account endpoint into Chart of Accounts
Surface the existing POST /api/v1/admin/accounts endpoint in the UI: a
super-user-only 'Add Account' button on the Chart of Accounts card opens
a dialog for the hierarchical account name + optional description, posts
with the wallet admin key (require_super_user), then reloads accounts.
Client-side prefix validation mirrors the server's _VALID_ACCOUNT_PREFIXES.
No currency input — an Open directive does not require currency constraints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:31:42 +02:00
788a9998f6 fix(fava): escape string metadata + make Open currencies optional
add_account wrote free-text metadata values straight into the ledger
source via /api/source with no escaping — an unescaped quote or newline
in an admin-supplied description would corrupt the Beancount file (or
forge extra metadata lines). Escape backslash/quote/newline per the
tokenizer's cunescape rules (verified round-trip through beancount's
parser). Also make the currency constraint list optional so an Open
directive can be written unconstrained (currencies are an optional
part of the directive, not required).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:31:27 +02:00
16ae6c2000 docs(tests): record known-good lnbits/dev invocation + env gotchas
The suite targets the lnbits dev worktree (needs lnbits.core.signers)
and trips on three non-obvious environment requirements, each of which
cost a failed run today: LNBITS_EXTENSIONS_PATH is the parent of an
extensions/ dir, the data folder must be a fresh temp dir per run, and
lnbits dev mandates LNBITS_KEY_MASTER at boot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:39:14 +02:00
15d9910073 Resolve entry identity via entry-id metadata; unfuse user references (libra-#42)
Approving a pending entry created with a reference (e.g. invoice
"42-144") 404'd with "Pending entry unknown not found": the list
endpoints recovered the entry id by parsing links for a libra- prefix,
but reference-bearing entries displace that link with the fused
"{reference}-{entry_id}" form, so the id surfaced as the literal
"unknown" and the approve call round-tripped it.

Make the entry-id transaction metadata the single canonical identity:

- _extract_entry_id() resolves metadata-first (libra- link parsing kept
  only for pre-dfdcc44 ledger history); used by /entries/user,
  /entries/pending, approve, and reject.
- Creation endpoints no longer fuse the reference with the entry id —
  the user reference becomes its own sanitized link and round-trips
  verbatim in API responses. Typed exp-/rcv-/inc- links stay as the
  settlement-tracking handles.
- format_revenue_entry now writes entry-id metadata like its siblings
  and sanitizes its reference link (was appended raw); generic
  POST /entries sanitizes its reference link too.
- User-journal reference extraction skips all system link prefixes
  (typed links used to leak into the reference field).

Contract documented in CLAUDE.md (Data Integrity → Entry Identity &
Links), pinned by tests/test_entry_identity_api.py and formatter
contract tests in test_unit.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:39:06 +02:00
116df46d38 Net settlement + credit overflow on /receivables/settle (libra-#33, libra-#41)
When the caller omits settled_entry_links (the default), the endpoint
auto-detects open entries across both directions for the user and writes
a single transaction that:

  - Zeros every per-user account that has an open balance, not just the
    net (the libra-#33 bug — previously the 2-leg form left both Payable
    and Receivable carrying non-zero balances after a complete cash
    settlement, while only netting the cash side).
  - Routes any cash above the net obligation to Liabilities:Credit:User-X
    (libra-#41), so over-payment lands on a real liability account
    instead of silently drifting.
  - Attaches every reconciled source entry's link
    (exp-..., rcv-...) so a reader scanning the settlement transaction
    can trace what it cleared.

Cash less than the net obligation, with no explicit links, returns 400
with a structured diff (cash_paid, net_obligation, receivable_total,
payable_total). The operator either pays the exact net or passes
settled_entry_links to settle a specific subset; partial settlement
without a coherent target is not silently absorbed.

The legacy explicit-links code path is unchanged — callers that pass
settled_entry_links keep the 2-leg shape with no auto-detection. None
of the callers in libra or aiolabs/webapp currently use that field, but
the contract is preserved for the partial-settle-of-specific-entries
flow.

format_fiat_net_settlement_entry is the new helper for the 2/3/4-leg
shape; it enforces the cash-balance constraint inline so callers can't
accidentally produce an unbalanced transaction.

tests/test_settlement_api.py (6 tests) locks in:
  - Nancy's #33 scenario: receivable 100 + payable 50 + cash 50
    zeros both per-user accounts, links both source entries
  - Overpay: cash 70 against net 50 → credit balance 20
  - Pure receivable overpay → credit appears
  - Underpay without explicit links → 400 with diff
  - No open receivables → 400 with hint pointing at /payables/pay
  - Explicit settled_entry_links uses legacy 2-leg path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 15:39:45 +02:00
50658440a4 Surface user credit balance in GET /balance per libra-#41
Extends get_user_balance_bql and get_all_user_balances_bql to fold
Liabilities:Credit:User-X into the same query as Payable and Receivable.
Credit is the overpay-absorbing liability that libra owes the user going
forward — it carries the same sign as Payable, so the existing fiat
aggregation subtracts it from net obligation without further changes.

Adds UserBalance.account_balances to surface the BQL per-account
breakdown so libra extension UI and webapp can render Payable /
Receivable / Credit as distinct line items. The legacy `accounts` field
stays empty for back-compat with anything reading the older shape.

Prepares for libra-#33 / libra-#41: settlement netting (#14 task) will
write the overflow leg to credit; this changeset makes sure that, the
moment credit exists, the displayed net everywhere already reflects it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 15:39:45 +02:00
7a4b3022c2 Add integration test suite
113 passing tests + 3 skipped + 8 xfailed across 10 files, covering user
expense and income flow, admin receivable/revenue, settings + auth gates,
void/reject, manual payment requests, balance display, Lightning auth
paths, reconciliation API, and pure-function units. Runs against a real
Fava subprocess and full LNbits app via asgi_lifespan; the harness
captures the auth-flow / settings / env-var disciplines surfaced during
build-out (see tests/README.md and tests/conftest.py docstring).

Eight xfailed/skipped tests carry full implementations gated behind issues
#38, #39, #40 — they flip back on automatically when those land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 15:39:45 +02:00
9c88993c13 Merge pull request 'Show voided transactions in user-entries endpoint' (#34) from feat/show-voided-user-entries into main
Reviewed-on: aiolabs/libra#34
2026-06-07 13:38:30 +00:00
781059af5f fix(dashboard): detect voided rows by tag, not by flag char
The admin transactions table assumed voided entries used flag='x', but
the libra reject convention keeps the '!' flag and appends a 'voided'
tag. Without this, the dashboard rendered voided rows as orange 'Pending'
once they started reaching it. Detect via tag and give the voided icon
precedence over the flag-based branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 20:47:42 +02:00
1c89e69030 feat(api): include voided transactions in user-entries endpoint
The /api/v1/entries/user view was silently dropping any transaction tagged
'voided', so users couldn't see entries that had been rejected against
their accounts. Per the libra reject convention, voided entries keep the
'!' flag and carry a 'voided' tag for audit; clients can use the tag to
style them distinctly. Pending-approval listing still filters voided.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 20:47:27 +02:00
68cdc4f9ee Merge pull request 'Route account writes for the split Fava ledger layout' (#32) from feat/split-ledger into main
Reviewed-on: aiolabs/libra#32
2026-06-06 18:03:25 +00:00
9e7795b541 add_account: always append at end of file
The original "find last Open directive, insert after its metadata" logic
was a clever optimisation for the monolithic ledger where opens, txns,
and assertions all lived in one file -- you wanted new opens grouped with
existing opens, not appended after a long transaction tail.

Post-split, each include file has one mutation profile:
  - accounts/chart.beancount: only Open directives
  - accounts/users.beancount: only Open directives
  - transactions.beancount:   only Transactions

There is no longer a content shape that benefits from mid-file insertion;
the existing heuristic also had a pre-existing bug where it only matched
'open ' OR '{current_year}-' as line prefixes, so 1970-* seed opens were
invisible and the search "stuck" to the first current-year line in the
file (which on aio-demo ended up being the wrong place).

Drop the search; always append. Simpler, chronological, append-only
friendly.

Refs: aiolabs/libra#28
2026-06-06 19:39:55 +02:00
09a5d6ed55 Polish account-creation flow: insertion point, user_id consistency, startup race
Three small fixes shaken out by live testing on aio-demo:

1. fava_client.add_account: when the target file has no Open directives
   yet (e.g. the empty accounts/users.beancount seed), append at end of
   file instead of inserting at index 0. Keeps the seed header comments
   at the top where they belong.

2. account_sync.sync_single_account_from_beancount: read the full user_id
   from Beancount metadata when present, fall back to the name-derived
   8-char prefix otherwise. crud.get_or_create_user_account writes the
   full 32-char user_id into Beancount metadata when creating per-user
   accounts; the sync function was only looking at the account name and
   returning the prefix, so the post-sync `WHERE user_id=:user_id` query
   in crud.py missed the row and fell through the UNIQUE-constraint
   recovery path. Three lines of warning noise per user-account creation.

3. tasks.wait_for_account_sync: await `wait_for_fava_client()` (new
   helper backed by an asyncio.Event in fava_client.py) before the first
   sync iteration. Previously the sync task started in libra_start()
   raced the fire-and-forget `_init_fava()` coroutine and reliably
   crashed the first run with "Fava client not initialized".

Refs: aiolabs/libra#28
2026-06-06 19:36:39 +02:00
d82443d040 fava_client: resolve relative target_file paths against the ledger root
Fava's /api/source endpoint rejects relative paths with HTTP 500
(NonSourceFileError: "Trying to read a non-source file at '...'"). The
include-aware `_infer_target_file` helper returns relative paths
(e.g. "accounts/users.beancount"), so add a `_resolve_target_file`
hook that prepends the ledger root directory.

The dirname is derived from a one-time GET /api/options and cached on
the FavaClient instance (which is a module-level singleton), guarded by
an asyncio.Lock so concurrent first-callers don't double-fetch.

Absolute paths pass through unchanged, so the admin endpoint that
explicitly passes target_file="accounts/chart.beancount" works the same
as one that passes "/var/lib/fava/accounts/chart.beancount".

Verified against aio-demo's live fava: relative paths now produce
HTTP 200 reads on options.beancount, accounts/chart.beancount,
accounts/users.beancount, and transactions.beancount.

Refs: aiolabs/libra#28
2026-06-06 19:16:50 +02:00
34ecb3f249 Add POST /api/v1/admin/accounts for chart-of-accounts entries
Companion to the fava ledger split (aiolabs/server-deploy#4). Super-user
endpoint that adds a new Open directive to accounts/chart.beancount via
fava_client.add_account (explicit target_file), then mirrors the account
into Libra's DB via sync_single_account_from_beancount so permissions can
be granted on it.

Validates the account name against the five Beancount top-level prefixes
(Assets:/Liabilities:/Equity:/Income:/Expenses:) and returns 400 on a bad
prefix.

Per-user accounts (matching :User-xxxxxxxx) keep their existing code path
via crud.get_or_create_user_account, which inherits the inferred target_file
(accounts/users.beancount) from the add_account default.

Backend only -- the LNbits admin UI on top is tracked separately as
aiolabs/libra#30.

Refs: aiolabs/libra#29
2026-06-06 15:30:23 +02:00
894de72953 Route fava_client.add_account writes per account type
The Fava-backed ledger is being split into purpose-specific files (see
aiolabs/server-deploy#4): accounts/chart.beancount for static + admin-managed
opens, accounts/users.beancount for libra-appended per-user opens.

Add a `target_file` parameter to `add_account` that defaults to inference
from the account name (`:User-[0-9a-f]{8}$` -> users.beancount, otherwise
chart.beancount). Drop the now-redundant `GET /api/options` call that was
only used to discover the root file path. Callers that need explicit
control (e.g. the upcoming admin chart-edit endpoint) can pass
`target_file=` directly.

The retry loop, write lock, and insertion-point search are unchanged --
each included file is a self-contained source the existing logic operates
on cleanly.

Refs: aiolabs/libra#28
2026-06-06 15:27:28 +02:00
f0899bf788 Update CLAUDE.md to reflect Fava-as-sole-source-of-truth for journal entries
The previous journal_entries/entry_lines local mirror was removed during
the Fava migration but the docs still described it as a local cache.
Replace with explicit statement that Fava is canonical and the remaining
SQLite tables hold orthogonal operational state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 14:48:57 +02:00
1201557f0c Gate cross-user admin endpoints behind require_super_user
Twenty-six endpoints documented "(admin only)" were using
require_admin_key, which only checks the caller owns a wallet with
its admin key — not LNbits-instance admin. Any logged-in user could
fabricate receivables against any other user_id, grant themselves
MANAGE permission on any account, create + self-assign privileged
roles, etc.

Swaps Depends(require_admin_key) -> Depends(require_super_user) on:
receivable/revenue creation, equity-eligibility grant/revoke/list,
permission grant/list/revoke/bulk/bulk-grant, account-sync admin,
role + role-permission + user-role CRUD, cross-user contributions
and unsettled-entries reports.

Also deletes the unsafe duplicate /api/v1/pay-user — both function
defs shared the name api_pay_user, the second shadowed the first at
module scope but FastAPI registered both routes. /api/v1/payables/pay
already provides the super-user-gated equivalent.

Two pre-existing orphan wallet.wallet.user references inside
api_settle_receivable and api_approve_manual_payment_request (both
already used the auth parameter) would have raised NameError at
runtime; fixed in passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 22:57:59 +02:00
0a7c39adcb Show Outstanding Balances split per direction per currency
When a user has entries in multiple currencies that go in opposite
directions — e.g. an income entry in EUR (user owes the org) and an
expense entry in CAD (org owes user) — the previous row collapsed
both into a single "Owes you" / "You owe" label driven by the net
sats balance. The fiat amounts were displayed via Math.abs(), hiding
the per-currency signs the backend already returns, so the row was
actively misleading: it showed €200 and CA$300 under one direction
when in reality they point in opposite directions.

Render up to two grouped lines instead — "Owes you €200.00" and
"You owe CA$300.00" — using new owesYouFiat / youOweFiat helpers
that filter the signed fiat_balances dict by sign. Net sats stays
as a small caption with an explicit "(receivable)"/"(payable)"
qualifier, since sats can be netted but distinct fiat currencies
can't without a spot rate. Falls back to the old single-line render
when there are no fiat balances (sats-only entries).
2026-05-17 20:12:51 +02:00
deeec7e2c5 Add lifetime income/expense totals to UserBalance
New get_user_lifetime_totals_bql() runs tag-filtered BQL queries
(Payable + expense-entry, Receivable + income-entry) to compute
per-user lifetime totals separately from the net balance. Plumbed
through /api/v1/balance and /api/v1/balance/{user_id}; existing
clients keep working (fields default to zero / empty dict).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:06:16 +02:00
483e89163e Tag each pending approval row with an INCOME / EXPENSE badge
With both kinds of entry sharing the Pending Approvals list, the row
alone didn't tell the reviewer which direction the accounting goes.
Adds a small green INCOME / red EXPENSE badge as a caption line above
the description (so it doesn't compete with description wrapping),
driven by an isIncomeEntry(entry) helper that reads the Beancount tag
set the API already returns. Also drops the now-redundant orange
pending-icon avatar — the card title already says these are pending,
and the badge does the heavier lifting.
2026-05-17 15:26:34 +02:00
6a110545e2 Rename Pending Approvals card from "Pending Expense Approvals"
Both expense and income entries land in the same pending list (the
backend's /entries/pending endpoint already returns all pending
transactions regardless of type, and approve/reject is type-agnostic),
so the expense-specific title was misleading once income approval
shipped in #13.
2026-05-17 15:21:46 +02:00
1edd126a43 Reset role permission/user state when closing the role dialog
closeViewRoleDialog already clears rolePermissionsForView and
roleUsersForView; closeRoleDialog (used by both Edit and Create flows)
did not. With editRole now populating those arrays, leftover state
would otherwise survive a close → open-Create round trip. The Create
template branch doesn't read the arrays today (v-if guarded on
editingRole), so this is defensive — keeps the two close handlers
symmetrical and avoids future regressions if the Create branch ever
starts referencing them.
2026-05-17 13:52:50 +02:00
55f8249f2c Load role permissions when opening the Edit Role dialog
The dialog reads from rolePermissionsForView / roleUsersForView, but
editRole(role) only ever populated the form fields and showed the
dialog — those arrays were left at whatever state the rest of the page
had set them to. Result: opening Edit Role for a role with existing
permissions showed "No permissions assigned to this role yet", and the
list only "appeared" because adding a permission triggered a refresh.

Mirror viewRole's pattern: clear both arrays, GET /admin/roles/{id},
populate from the response, then show the dialog after $nextTick.

Closes #14
2026-05-17 13:52:36 +02:00
0f2a38ee7f Record income receipts as a user receivable, not an entity asset
When a user submits income, the money is physically in *their* pocket,
not the entity's cash drawer. The original income endpoint posted DR
on a configurable payment-method asset account (Cash/Bank/Lightning),
which implicitly assumed the entity already had the funds.

Mirror the expense flow instead: DR Assets:Receivable:User-{id[:8]}
(via get_or_create_user_account), CR the revenue account. The user
now owes the entity until they hand the cash over via the existing
/settle-receivable workflow. With this, the per-user Outstanding
Balances card correctly nets expenses (entity owes user, -liability)
against income receipts (user owes entity, +receivable).

Drops payment_method_account from IncomeEntry — no longer needed.
2026-05-16 23:40:08 +02:00
61952d0015 Expose SUBMIT_INCOME in permission management UI
Adds the new permission type to the grant/bulk-grant dialog dropdown
(static/js/permissions.js) so admins can grant 'Submit Income' on
revenue accounts the same way they grant 'Submit Expense' on expense
accounts. Without this, the backend's SUBMIT_INCOME check on the new
income endpoint is ungranted-able from the UI and users see a 403.

Uses 'teal' + the 'payments' icon to distinguish income-grant badges
from green-and-add_circle expense-grant badges in the role/account
permission lists. Also updates a stale comment in migrations.py
listing the valid permission_type values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:55:28 +02:00
93b5c2677c Add user-facing income/revenue submission endpoint
Mirrors the existing expense submission flow so non-admin users can log
income on behalf of the organization for super-user review. New endpoint
POST /api/v1/entries/income takes invoice-key auth, creates a Beancount
transaction with the pending '!' flag, and reuses the existing
/entries/{id}/approve and /reject endpoints (which match by libra-{id}
link regardless of entry type).

Adds PermissionType.SUBMIT_INCOME granted on revenue accounts (parallel
to SUBMIT_EXPENSE on expense accounts) rather than overloading
SUBMIT_EXPENSE — the two operations target distinct account types and
should be grantable independently. Enforces AccountType.REVENUE on the
income account and AccountType.ASSET on the payment-method account;
fiat currency is required (matches the expense flow's effective
requirement). Income entries get a 'income-entry' tag and an
^inc-{entry_id} link for tracking, and surface in the existing
/entries/pending list for super-user approval.

UI work lives in the standalone webapp, out of scope here.

Closes #9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:01:47 +02:00
4085280711 Filter synthetic Beancount entries from journal listings
The Recent Transactions card was showing Beancount-generated opening-balance
entries from ledger summarization (flag 'S'). Adds a _SYNTHETIC_FLAGS set
mirroring Fava's _EXCL_FLAGS (S/T/C/P/U/R/M) and skips matching entries in
the two user-facing endpoints that previously only filtered by transaction
type. Other journal callers already filter by flag '!' so are unaffected.

Closes #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:43:32 +02:00
b2b2c109a4 Clean up awkward "the Libra" phrasing left over from rename
Replaces entity-sense references to "the Libra" with "the
organization"/"the collective" where Libra was being used as a
stand-in for the original "Castle" entity, and drops the redundant
"(like cooperatives)" parenthetical in DOCUMENTATION.md. Also swaps
the 🏰 emoji in the import helper for ⚖️.

Closes #12

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:37:30 +02:00
c174cda48d Rename Castle Accounting extension to Libra
Full identifier rename: module path lnbits.extensions.castle →
lnbits.extensions.libra, DB ext_castle → ext_libra, URL prefix
/castle/ → /libra/, manifest id castle → libra, fava ledger slug
default castle-ledger → libra-ledger, Beancount source metadata
castle-api → libra-api and link prefixes castle-{entry,tx}- →
libra-{entry,tx}-, column castle_wallet_id → libra_wallet_id, all
Python/JS/HTML identifiers (castle_ext, CastleSettings,
castle_reference, castleWalletConfigured, etc.).

Display name "Castle Accounting" → "Libra" (the scales/balance
metaphor — fits double-entry bookkeeping).

No backward compat: production hosts will be force-updated. Old
castle-prefixed Beancount metadata in existing Fava ledgers is
historical; new entries use libra-* prefixes going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:24:46 +02:00
9c577c740c Add account sync button to super user toolbar
Wires the existing POST /api/v1/admin/accounts/sync endpoint into the
Castle index toolbar (sync icon between permissions and settings).
Surfaces sync stats (added/reactivated/deactivated/virtual_parents/errors)
via a Quasar notification and refreshes the accounts list on success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:51:16 +02:00
9a1893c546 Fix startup: load Fava settings from DB instead of hardcoded defaults
castle_start() was using CastleSettings() defaults (slug=castle-ledger)
instead of reading the saved settings from the database. This caused all
Fava queries to 404 on instances where the ledger slug differs from the
default (e.g. demo-ledger).

Now loads settings from extension_settings table at startup, falling
back to defaults only if no saved settings exist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 19:08:13 +02:00
f2f9183106 Fix get_all_accounts to discover accounts from open directives
The previous BQL query (SELECT DISTINCT account) only returned accounts
with postings, missing all accounts that were opened but had no
transactions yet. On a fresh ledger this returned 0 accounts, causing
the account sync to deactivate everything.

Now uses Fava's balance_sheet and income_statement API endpoints which
return the full account tree including zero-balance accounts. Falls back
to BQL if the tree endpoints fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 18:54:54 +02:00
Patrick Mulligan
b06c53c40f Fix BQL balance queries mixing EUR and SATS face values
The BQL queries in get_user_balance_bql() and get_all_user_balances_bql()
used GROUP BY account without currency, causing sum(number) to add EUR
face values from expense entries (EUR @@ SATS notation) with SATS face
values from payment entries (plain SATS). This inflated displayed fiat
amounts by orders of magnitude for users with settlement payments.

Fix: add currency to GROUP BY so EUR and SATS rows are separate, use
sum(weight) for net SATS (correct across all entry formats), and scale
fiat proportionally for partial settlements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:43:38 -04:00
Patrick Mulligan
c4f784360d Fix Pay User lightning payment bugs
- Fix default amount showing fiat instead of sats when lightning payment selected
- Fix invoice response field name (bolt11 instead of payment_request)
- Fix NameError in payables/pay endpoint (wallet -> auth.user_id)
- Add get_user_wallet_settings_by_prefix() for truncated 8-char user IDs
- Update user-wallet endpoint to handle truncated IDs from Beancount accounts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 07:17:12 -05:00
8d9e14ee5a Fix approve/reject endpoints to use Fava source API correctly
The Fava /context endpoint returns structured entry data, not raw source
text with slice/sha256sum as expected. Updated both endpoints to:

1. Get entry metadata (filename, lineno) from the parsed entry
2. Read the full source file via GET /source
3. Modify the specific line at the entry's line number
4. Write back via PUT /source with sha256sum for concurrency control

- Approve: Changes flag from '!' to '*' at the entry line
- Reject: Adds #voided tag to the entry line

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 15:47:09 +01:00
cb9bc2d658 Add Fava settings UI and fix race conditions in toolbar buttons
- Add Fava URL, ledger slug, and timeout settings to super admin Settings dialog
- Reinitialize Fava client when settings are updated via services.py
- Add settingsLoaded flag to prevent race conditions where wrong toolbar
  buttons appeared before isSuperUser was determined
- Remove premature Vue mount() call from permissions.js that caused
  "Cannot read properties of undefined (reading 'user')" error

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 15:24:19 +01:00
5eb007b936 Merge branch 'fix/authorization-security-refactor' 2026-01-07 13:37:54 +01:00
b5c36504fb Add concurrency protection for Fava/Beancount ledger writes
This commit addresses critical race conditions when multiple requests
try to write to the ledger file simultaneously.

Changes:
- Add global asyncio.Lock to FavaClient to serialize all write operations
- Add per-user locks for finer-grained concurrency control
- Wrap add_entry(), update_entry_source(), delete_entry() with write lock
- Add retry logic with exponential backoff to add_account() for checksum conflicts
- Add new add_entry_idempotent() method to prevent duplicate entries
- Add ChecksumConflictError exception for conflict handling
- Update on_invoice_paid() to use per-user locking and idempotent entry creation

Fixes #4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:57:03 +01:00
61 changed files with 7619 additions and 1523 deletions

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Castle Accounting is a double-entry bookkeeping extension for LNbits that enables collectives (co-living spaces, makerspaces, community projects) to track finances with proper accounting principles. It integrates Lightning Network payments with traditional accounting, supporting both cryptocurrency and fiat currency tracking.
Libra is a double-entry bookkeeping extension for LNbits that enables collectives (co-living spaces, makerspaces, community projects) to track finances with proper accounting principles. It integrates Lightning Network payments with traditional accounting, supporting both cryptocurrency and fiat currency tracking.
## Architecture
@ -12,9 +12,9 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable
**Double-Entry Accounting**: Every transaction affects at least two accounts. Debits must equal credits. Five account types: Assets, Liabilities, Equity, Revenue (Income), Expenses.
**Fava/Beancount Backend**: Castle now uses [Fava](https://github.com/beancount/fava) as the primary accounting engine. Fava is a web interface for Beancount that provides a REST API for ledger operations. All accounting calculations (balance sheets, trial balances, account reports) are delegated to Fava/Beancount. Castle formats transactions as Beancount entries and submits them via Fava's API.
**Fava/Beancount Backend**: Libra now uses [Fava](https://github.com/beancount/fava) as the primary accounting engine. Fava is a web interface for Beancount that provides a REST API for ledger operations. All accounting calculations (balance sheets, trial balances, account reports) are delegated to Fava/Beancount. Libra formats transactions as Beancount entries and submits them via Fava's API.
**Required External Dependency**: Fava must be running as a separate service. Configure `fava_url` and `fava_ledger_slug` in Castle settings (default: `http://localhost:3333` with slug `castle-accounting`). Castle will not function without Fava.
**Required External Dependency**: Fava must be running as a separate service. Configure `fava_url` and `fava_ledger_slug` in Libra settings (default: `http://localhost:3333` with slug `libra-accounting`). Libra will not function without Fava.
**Pure Functional Core**: The `core/` directory contains pure accounting logic independent of the database layer:
- `core/validation.py` - Entry validation rules
@ -44,23 +44,19 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable
- `tasks.py` - Background tasks (invoice payment monitoring)
- `account_utils.py` - Hierarchical account naming utilities
- `fava_client.py` - HTTP client for Fava REST API (add_entry, query, balance_sheet)
- `beancount_format.py` - Converts Castle entries to Beancount transaction format
- `beancount_format.py` - Converts Libra entries to Beancount transaction format
- `core/validation.py` - Pure validation functions for accounting rules
### Database Schema
**Note**: With Fava integration, Castle maintains a local cache of some data but delegates authoritative balance calculations to Beancount/Fava.
**Fava is the sole source of truth for journal entries.** Libra does NOT maintain a local mirror of transactions — the previous `journal_entries` and `entry_lines` tables were removed during the Fava migration. All transaction reads (history, balances, summaries) go through Fava's HTTP API; writes go through `PUT /api/add_entries` and are serialized via `FavaClient._write_lock`. When retrieving journal entries from Fava for UI display, results are enriched with a `username` field from LNbits user data.
**journal_entries**: Transaction headers stored locally and synced to Fava
- `flag` field: `*` (cleared), `!` (pending), `#` (flagged), `x` (void)
- `meta` field: JSON storing source, tags, audit info
- `reference` field: Links to payment_hash, invoice numbers, etc.
- Enriched with `username` field when retrieved via API (added from LNbits user data)
The SQLite tables below hold **operational state** that Fava doesn't (and shouldn't) own — workflow, RBAC, settings, reconciliation assertions. None of it is derivable from the Beancount file; they are independent stores, not caches.
**extension_settings**: Castle wallet configuration (admin-only)
- `castle_wallet_id` - The LNbits wallet used for Castle operations
**extension_settings**: Libra wallet configuration (admin-only)
- `libra_wallet_id` - The LNbits wallet used for Libra operations
- `fava_url` - Fava service URL (default: http://localhost:3333)
- `fava_ledger_slug` - Ledger identifier in Fava (default: castle-accounting)
- `fava_ledger_slug` - Ledger identifier in Fava (default: libra-accounting)
- `fava_timeout` - API request timeout in seconds
**user_wallet_settings**: Per-user wallet configuration
@ -70,22 +66,22 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable
## Transaction Flows
### User Adds Expense (Liability)
User pays cash for groceries, Castle owes them:
User pays cash for groceries, Libra owes them:
```
DR Expenses:Food 39,669 sats
CR Liabilities:Payable:User-af983632 39,669 sats
```
Metadata preserves: `{"fiat_currency": "EUR", "fiat_amount": "36.93", "fiat_rate": "1074.192"}`
### Castle Adds Receivable
User owes Castle for accommodation:
### Libra Adds Receivable
User owes Libra for accommodation:
```
DR Assets:Receivable:User-af983632 268,548 sats
CR Income:Accommodation 268,548 sats
```
### User Pays with Lightning
Invoice generated on **Castle's wallet** (not user's). After payment:
Invoice generated on **Libra's wallet** (not user's). After payment:
```
DR Assets:Lightning:Balance 268,548 sats
CR Assets:Receivable:User-af983632 268,548 sats
@ -101,14 +97,14 @@ DR Liabilities:Payable:User-af983632 39,669 sats
## Balance Calculation Logic
**User Balance** (calculated by Beancount via Fava):
- Positive = Castle owes user (LIABILITY accounts have credit balance)
- Negative = User owes Castle (ASSET accounts have debit balance)
- Positive = Libra owes user (LIABILITY accounts have credit balance)
- Negative = User owes Libra (ASSET accounts have debit balance)
- Calculated by querying Fava for sum of all postings across user's accounts
- Fiat balances calculated by Beancount from cost basis annotations, NOT converted from current sats
**Perspective-Based UI**:
- **User View**: Green = Castle owes them, Red = They owe Castle
- **Castle Admin View**: Green = User owes Castle, Red = Castle owes user
- **User View**: Green = Libra owes them, Red = They owe Libra
- **Libra Admin View**: Green = User owes Libra, Red = Libra owes user
**Balance Retrieval**: Use `GET /api/v1/balance` which queries Fava's balance sheet or account reports for accurate, Beancount-calculated balances.
@ -127,12 +123,12 @@ DR Liabilities:Payable:User-af983632 39,669 sats
- `POST /api/v1/entries` - Create raw journal entry (admin only)
### Payments & Balances
- `GET /api/v1/balance` - Get user balance (or Castle total if super user)
- `GET /api/v1/balance` - Get user balance (or Libra total if super user)
- `GET /api/v1/balances/all` - Get all user balances (admin, enriched with usernames)
- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Castle
- `POST /api/v1/record-payment` - Record Lightning payment from user to Castle
- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Libra
- `POST /api/v1/record-payment` - Record Lightning payment from user to Libra
- `POST /api/v1/settle-receivable` - Manually settle receivable (cash/bank)
- `POST /api/v1/pay-user` - Castle pays user (cash/bank/lightning)
- `POST /api/v1/pay-user` - Libra pays user (cash/bank/lightning)
### Manual Payment Requests
- `POST /api/v1/manual-payment-requests` - User requests payment
@ -148,8 +144,8 @@ DR Liabilities:Payable:User-af983632 39,669 sats
- `POST /api/v1/tasks/daily-reconciliation` - Run daily reconciliation (admin)
### Settings
- `GET /api/v1/settings` - Get Castle settings (super user)
- `PUT /api/v1/settings` - Update Castle settings (super user)
- `GET /api/v1/settings` - Get Libra settings (super user)
- `PUT /api/v1/settings` - Update Libra settings (super user)
- `GET /api/v1/user/wallet` - Get user wallet settings
- `PUT /api/v1/user/wallet` - Update user wallet settings
@ -213,7 +209,8 @@ entry = format_transaction(
{"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"}
],
tags=["groceries"],
links=["castle-entry-123"]
links=["exp-a1b2c3d4e5f60708"], # typed settlement link; identity goes in entry-id metadata
meta={"entry-id": "a1b2c3d4e5f60708"}
)
# Submit to Fava
@ -221,6 +218,8 @@ client = get_fava_client()
result = await client.add_entry(entry)
```
Prefer the purpose-built formatters (`format_expense_entry`, `format_income_entry`, …) over raw `format_transaction` — they write the `entry-id` metadata and typed links for you (see Data Integrity → Entry Identity & Links).
**Querying Balances**:
```python
# Query user balance from Fava
@ -241,15 +240,15 @@ balance_result = await client.query(
### Extension as LNbits Module
This extension follows LNbits extension structure:
- Registered via `castle_ext` router in `__init__.py`
- Registered via `libra_ext` router in `__init__.py`
- Static files served from `static/` directory
- Templates in `templates/castle/`
- Database accessed via `db = Database("ext_castle")`
- Templates in `templates/libra/`
- Database accessed via `db = Database("ext_libra")`
**Startup Requirements**:
- `castle_start()` initializes Fava client on extension load
- `libra_start()` initializes Fava client on extension load
- Background task `wait_for_paid_invoices()` monitors Lightning invoice payments
- Fava service MUST be running before starting LNbits with Castle extension
- Fava service MUST be running before starting LNbits with Libra extension
## Common Tasks
@ -282,7 +281,8 @@ entry = format_transaction(
{"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"}
],
tags=["utilities"],
links=["castle-tx-123"]
links=["exp-0123456789abcdef"],
meta={"entry-id": "0123456789abcdef"}
)
client = get_fava_client()
@ -310,6 +310,13 @@ result = await client.query(query)
3. User accounts use `user_id` (NOT `wallet_id`) for consistency
4. All accounting calculations delegated to Beancount/Fava
**Entry Identity & Links** (the contract `_extract_entry_id()` in views_api.py relies on):
- The `entry-id` **transaction metadata** is the single canonical entry identifier. Every libra-authored entry formatter (`format_expense_entry`, `format_receivable_entry`, `format_income_entry`, `format_revenue_entry`) writes it. All id resolution (pending list, user journal, approve, reject) reads it — never parse links to recover an id.
- Typed links `exp-{id}` / `rcv-{id}` / `inc-{id}` exist for **settlement tracking** (BQL queries match settlements to source entries by these links). They duplicate the id but are not the identity source.
- A user-supplied `reference` (invoice number, receipt id) becomes **its own sanitized link**, verbatim — never fused with the entry id, never displacing a system link. Two entries sharing a reference share the link (desired Beancount semantics).
- `ln-{payment_hash[:16]}` links mark Lightning payments.
- Legacy ledger history (pre-dfdcc44) carries a single `libra-{id}` link and no `entry-id` metadata — `_extract_entry_id()` falls back to parsing it. Do not write `libra-` links in new code.
**Validation** is performed in `core/validation.py`:
- Pure validation functions for entry correctness before submitting to Fava
@ -337,24 +344,24 @@ result = await client.query(query)
### Prerequisites
1. **LNbits**: This extension must be installed in the `lnbits/extensions/` directory
2. **Fava Service**: Must be running before starting LNbits with Castle enabled
2. **Fava Service**: Must be running before starting LNbits with Libra enabled
```bash
# Install Fava
pip install fava
# Create a basic Beancount file
touch castle-ledger.beancount
touch libra-ledger.beancount
# Start Fava (default: http://localhost:3333)
fava castle-ledger.beancount
fava libra-ledger.beancount
```
3. **Configure Castle Settings**: Set `fava_url` and `fava_ledger_slug` via settings API or UI
3. **Configure Libra Settings**: Set `fava_url` and `fava_ledger_slug` via settings API or UI
### Running Castle Extension
### Running Libra Extension
Castle is loaded as part of LNbits. No separate build or test commands are needed for the extension itself. Development workflow:
Libra is loaded as part of LNbits. No separate build or test commands are needed for the extension itself. Development workflow:
1. Modify code in `lnbits/extensions/castle/`
1. Modify code in `lnbits/extensions/libra/`
2. Restart LNbits
3. Extension hot-reloads are supported by LNbits in development mode
@ -363,13 +370,13 @@ Castle is loaded as part of LNbits. No separate build or test commands are neede
Use the web UI or API endpoints to create test transactions. For API testing:
```bash
# Create expense (user owes Castle)
curl -X POST http://localhost:5000/castle/api/v1/entries/expense \
# Create expense (user owes Libra)
curl -X POST http://localhost:5000/libra/api/v1/entries/expense \
-H "X-Api-Key: YOUR_INVOICE_KEY" \
-d '{"description": "Test expense", "amount": "100.00 EUR", "account_name": "Expenses:Test"}'
# Check user balance
curl http://localhost:5000/castle/api/v1/balance \
curl http://localhost:5000/libra/api/v1/balance \
-H "X-Api-Key: YOUR_INVOICE_KEY"
```

View file

@ -1,11 +1,11 @@
# Castle Migration Squash Summary
# Libra Migration Squash Summary
**Date:** November 10, 2025
**Action:** Squashed 16 incremental migrations into a single clean initial migration
## Overview
The Castle extension had accumulated 16 migrations (m001-m016) during development. Since the software has not been released yet, we safely squashed all migrations into a single clean `m001_initial` migration.
The Libra extension had accumulated 16 migrations (m001-m016) during development. Since the software has not been released yet, we safely squashed all migrations into a single clean `m001_initial` migration.
## Files Changed
@ -16,37 +16,37 @@ The Castle extension had accumulated 16 migrations (m001-m016) during developmen
The squashed migration creates **7 tables**:
### 1. castle_accounts
### 1. libra_accounts
- Core chart of accounts with hierarchical Beancount-style names
- Examples: "Assets:Bitcoin:Lightning", "Expenses:Food:Groceries"
- User-specific accounts: "Assets:Receivable:User-af983632"
- Includes comprehensive default account set (40+ accounts)
### 2. castle_extension_settings
- Castle-wide configuration
- Stores castle_wallet_id for Lightning payments
### 2. libra_extension_settings
- Libra-wide configuration
- Stores libra_wallet_id for Lightning payments
### 3. castle_user_wallet_settings
### 3. libra_user_wallet_settings
- Per-user wallet configuration
- Allows users to have separate wallet preferences
### 4. castle_manual_payment_requests
- User-submitted payment requests to Castle
### 4. libra_manual_payment_requests
- User-submitted payment requests to Libra
- Reviewed by admins before processing
- Includes notes field for additional context
### 5. castle_balance_assertions
### 5. libra_balance_assertions
- Reconciliation and balance checking at specific dates
- Multi-currency support (satoshis + fiat)
- Tolerance checking for small discrepancies
- Includes notes field for reconciliation comments
### 6. castle_user_equity_status
### 6. libra_user_equity_status
- Manages equity contribution eligibility
- Equity-eligible users can convert expenses to equity
- Creates dynamic user-specific equity accounts: Equity:User-{user_id}
### 7. castle_account_permissions
### 7. libra_account_permissions
- Granular access control for accounts
- Permission types: read, submit_expense, manage
- Supports hierarchical inheritance (parent permissions cascade)
@ -56,10 +56,10 @@ The squashed migration creates **7 tables**:
The following tables were **intentionally NOT included** in the final schema (they were dropped in m016):
- **castle_journal_entries** - Journal entries now managed by Fava/Beancount (external source of truth)
- **castle_entry_lines** - Entry lines now managed by Fava/Beancount
- **libra_journal_entries** - Journal entries now managed by Fava/Beancount (external source of truth)
- **libra_entry_lines** - Entry lines now managed by Fava/Beancount
Castle now uses Fava as the single source of truth for accounting data. Journal operations:
Libra now uses Fava as the single source of truth for accounting data. Journal operations:
- **Write:** Submit to Fava via FavaClient.add_entry()
- **Read:** Query Fava via FavaClient.get_entries()
@ -106,7 +106,7 @@ For reference, the original migration sequence (preserved in migrations_old.py.b
For new installations:
```bash
# Castle's migration system will run m001_initial automatically
# Libra's migration system will run m001_initial automatically
# No manual intervention needed
```
@ -174,20 +174,20 @@ After squashing, verify the migration works:
```bash
# 1. Backup existing database (if any)
cp castle.sqlite3 castle.sqlite3.backup
cp libra.sqlite3 libra.sqlite3.backup
# 2. Drop and recreate database to test fresh install
rm castle.sqlite3
rm libra.sqlite3
# 3. Start LNbits - migration should run automatically
poetry run lnbits
# 4. Verify tables created
sqlite3 castle.sqlite3 ".tables"
# Should show: castle_accounts, castle_extension_settings, etc.
sqlite3 libra.sqlite3 ".tables"
# Should show: libra_accounts, libra_extension_settings, etc.
# 5. Verify default accounts
sqlite3 castle.sqlite3 "SELECT COUNT(*) FROM castle_accounts;"
sqlite3 libra.sqlite3 "SELECT COUNT(*) FROM libra_accounts;"
# Should show: 40 (default accounts)
```
@ -200,12 +200,12 @@ If issues are discovered:
cp migrations_old.py.bak migrations.py
# Restore database
cp castle.sqlite3.backup castle.sqlite3
cp libra.sqlite3.backup libra.sqlite3
```
## Notes
- This squash is safe because Castle has not been released yet
- This squash is safe because Libra has not been released yet
- No existing production databases need migration
- Historical migrations preserved in migrations_old.py.bak
- All functionality preserved in final schema

View file

@ -1,10 +1,10 @@
# Castle Accounting Extension for LNbits
# Libra Extension for LNbits
A full-featured double-entry accounting system for collective projects, integrated with LNbits Lightning payments.
## Overview
Castle Accounting enables collectives like co-living spaces, makerspaces, and community projects to:
Libra enables collectives like co-living spaces, makerspaces, and community projects to:
- Track expenses and revenue with proper accounting
- Manage individual member balances
- Record contributions as equity or reimbursable expenses
@ -17,7 +17,7 @@ This extension is designed to be installed in the `lnbits/extensions/` directory
```bash
cd lnbits/extensions/
# Copy or clone the castle directory here
# Copy or clone the libra directory here
```
Enable the extension through the LNbits admin interface or by adding it to your configuration.
@ -30,7 +30,7 @@ Enable the extension through the LNbits admin interface or by adding it to your
- Choose "Liability" if you want reimbursement
- Choose "Equity" if it's a contribution
2. **View Your Balance**: See if the Castle owes you money or vice versa
2. **View Your Balance**: See if the collective owes you money or vice versa
3. **Pay Outstanding Balance**: Generate a Lightning invoice to settle what you owe
@ -54,8 +54,8 @@ Enable the extension through the LNbits admin interface or by adding it to your
### Account Types
- **Assets**: Things the Castle owns (Cash, Bank, Accounts Receivable)
- **Liabilities**: What the Castle owes (Accounts Payable to members)
- **Assets**: Things the organization owns (Cash, Bank, Accounts Receivable)
- **Liabilities**: What the organization owes (Accounts Payable to members)
- **Equity**: Member contributions and retained earnings
- **Revenue**: Income streams
- **Expenses**: Operating costs
@ -63,9 +63,9 @@ Enable the extension through the LNbits admin interface or by adding it to your
### Database Schema
The extension creates three tables:
- `castle.accounts` - Chart of accounts
- `castle.journal_entries` - Transaction headers
- `castle.entry_lines` - Debit/credit lines
- `libra.accounts` - Chart of accounts
- `libra.journal_entries` - Transaction headers
- `libra.entry_lines` - Debit/credit lines
## API Reference
@ -79,7 +79,7 @@ To modify this extension:
2. Add database migrations in `migrations.py`
3. Implement business logic in `crud.py`
4. Create API endpoints in `views_api.py`
5. Update UI in `templates/castle/index.html`
5. Update UI in `templates/libra/index.html`
## Contributing

View file

@ -5,24 +5,24 @@ from loguru import logger
from .crud import db
from .tasks import wait_for_paid_invoices
from .views import castle_generic_router
from .views_api import castle_api_router
from .views import libra_generic_router
from .views_api import libra_api_router
castle_ext: APIRouter = APIRouter(prefix="/castle", tags=["Castle"])
castle_ext.include_router(castle_generic_router)
castle_ext.include_router(castle_api_router)
libra_ext: APIRouter = APIRouter(prefix="/libra", tags=["Libra"])
libra_ext.include_router(libra_generic_router)
libra_ext.include_router(libra_api_router)
castle_static_files = [
libra_static_files = [
{
"path": "/castle/static",
"name": "castle_static",
"path": "/libra/static",
"name": "libra_static",
}
]
scheduled_tasks: list[asyncio.Task] = []
def castle_stop():
def libra_stop():
"""Clean up background tasks on extension shutdown"""
for task in scheduled_tasks:
try:
@ -31,35 +31,54 @@ def castle_stop():
logger.warning(ex)
def castle_start():
"""Initialize Castle extension background tasks"""
def libra_start():
"""Initialize Libra extension background tasks"""
from lnbits.tasks import create_permanent_unique_task
from .fava_client import init_fava_client
from .models import CastleSettings
from .models import LibraSettings
from .tasks import wait_for_account_sync
# Initialize Fava client with default settings
# (Will be re-initialized if admin updates settings)
defaults = CastleSettings()
try:
async def _init_fava():
"""Load saved settings from DB, fall back to defaults."""
from .crud import db as libra_db
settings = None
try:
row = await libra_db.fetchone(
"SELECT * FROM extension_settings LIMIT 1",
model=LibraSettings,
)
if row:
settings = row
logger.info(f"Loaded Libra settings from DB: {settings.fava_url}/{settings.fava_ledger_slug}")
except Exception as e:
logger.warning(f"Could not load settings from DB: {e}")
if not settings:
settings = LibraSettings()
logger.info(f"Using default Libra settings: {settings.fava_url}/{settings.fava_ledger_slug}")
init_fava_client(
fava_url=defaults.fava_url,
ledger_slug=defaults.fava_ledger_slug,
timeout=defaults.fava_timeout
fava_url=settings.fava_url,
ledger_slug=settings.fava_ledger_slug,
timeout=settings.fava_timeout
)
logger.info(f"Fava client initialized: {defaults.fava_url}/{defaults.fava_ledger_slug}")
logger.info(f"Fava client initialized: {settings.fava_url}/{settings.fava_ledger_slug}")
try:
asyncio.get_event_loop().create_task(_init_fava())
except Exception as e:
logger.error(f"Failed to initialize Fava client: {e}")
logger.warning("Castle will not function without Fava. Please configure Fava settings.")
logger.warning("Libra will not function without Fava. Please configure Fava settings.")
# Start background tasks
task = create_permanent_unique_task("ext_castle", wait_for_paid_invoices)
task = create_permanent_unique_task("ext_libra", wait_for_paid_invoices)
scheduled_tasks.append(task)
# Start account sync task (runs hourly)
sync_task = create_permanent_unique_task("ext_castle_account_sync", wait_for_account_sync)
sync_task = create_permanent_unique_task("ext_libra_account_sync", wait_for_account_sync)
scheduled_tasks.append(sync_task)
logger.info("Castle account sync task started (runs hourly)")
logger.info("Libra account sync task started (runs hourly)")
__all__ = ["castle_ext", "castle_static_files", "db", "castle_start", "castle_stop"]
__all__ = ["libra_ext", "libra_static_files", "db", "libra_start", "libra_stop"]

View file

@ -1,11 +1,11 @@
"""
Account Synchronization Module
Syncs accounts from Beancount (source of truth) to Castle DB (metadata store).
Syncs accounts from Beancount (source of truth) to Libra DB (metadata store).
This implements the hybrid approach:
- Beancount owns account existence (Open directives)
- Castle DB stores permissions and user associations
- Libra DB stores permissions and user associations
- Background sync keeps them in sync
Related: ACCOUNTS-TABLE-REMOVAL-FEASIBILITY.md - Phase 2 implementation
@ -89,14 +89,14 @@ def extract_user_id_from_account_name(account_name: str) -> Optional[str]:
async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
"""
Sync accounts from Beancount to Castle DB.
Sync accounts from Beancount to Libra DB.
This ensures Castle DB has metadata entries for all accounts that exist
This ensures Libra DB has metadata entries for all accounts that exist
in Beancount, enabling permissions and user associations to work properly.
New behavior (soft delete + virtual parents):
- Accounts in Beancount but not in Castle DB: Added as active
- Accounts in Castle DB but not in Beancount: Marked as inactive (soft delete)
- Accounts in Beancount but not in Libra DB: Added as active
- Accounts in Libra DB but not in Beancount: Marked as inactive (soft delete)
- Inactive accounts that return to Beancount: Reactivated
- Missing intermediate parents: Auto-created as virtual accounts
@ -113,7 +113,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
dict with sync statistics:
{
"total_beancount_accounts": 150,
"total_castle_accounts": 148,
"total_libra_accounts": 148,
"accounts_added": 2,
"accounts_updated": 0,
"accounts_skipped": 148,
@ -123,7 +123,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
"errors": []
}
"""
logger.info("Starting account sync from Beancount to Castle DB")
logger.info("Starting account sync from Beancount to Libra DB")
fava = get_fava_client()
@ -134,7 +134,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
logger.error(f"Failed to fetch accounts from Beancount: {e}")
return {
"total_beancount_accounts": 0,
"total_castle_accounts": 0,
"total_libra_accounts": 0,
"accounts_added": 0,
"accounts_updated": 0,
"accounts_skipped": 0,
@ -143,16 +143,16 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
"errors": [str(e)],
}
# Get all accounts from Castle DB (including inactive ones for sync)
castle_accounts = await get_all_accounts(include_inactive=True)
# Get all accounts from Libra DB (including inactive ones for sync)
libra_accounts = await get_all_accounts(include_inactive=True)
# Build lookup maps
beancount_account_names = {acc["account"] for acc in beancount_accounts}
castle_accounts_by_name = {acc.name: acc for acc in castle_accounts}
libra_accounts_by_name = {acc.name: acc for acc in libra_accounts}
stats = {
"total_beancount_accounts": len(beancount_accounts),
"total_castle_accounts": len(castle_accounts),
"total_libra_accounts": len(libra_accounts),
"accounts_added": 0,
"accounts_updated": 0,
"accounts_skipped": 0,
@ -162,15 +162,15 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
"errors": [],
}
# Step 1: Sync accounts from Beancount to Castle DB
# Step 1: Sync accounts from Beancount to Libra DB
for bc_account in beancount_accounts:
account_name = bc_account["account"]
try:
existing = castle_accounts_by_name.get(account_name)
existing = libra_accounts_by_name.get(account_name)
if existing:
# Account exists in Castle DB
# Account exists in Libra DB
# Check if it needs to be reactivated
if not existing.is_active:
await update_account_is_active(existing.id, True)
@ -181,7 +181,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
logger.debug(f"Account already active: {account_name}")
continue
# Create new account in Castle DB
# Create new account in Libra DB
account_type = infer_account_type_from_name(account_name)
user_id = extract_user_id_from_account_name(account_name)
@ -207,25 +207,25 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
logger.error(error_msg)
stats["errors"].append(error_msg)
# Step 2: Mark orphaned accounts (in Castle DB but not in Beancount) as inactive
# Step 2: Mark orphaned accounts (in Libra DB but not in Beancount) as inactive
# SKIP virtual accounts (they're intentionally metadata-only)
for castle_account in castle_accounts:
if castle_account.is_virtual:
for libra_account in libra_accounts:
if libra_account.is_virtual:
# Virtual accounts are metadata-only, never deactivate them
continue
if castle_account.name not in beancount_account_names:
if libra_account.name not in beancount_account_names:
# Account no longer exists in Beancount
if castle_account.is_active:
if libra_account.is_active:
try:
await update_account_is_active(castle_account.id, False)
await update_account_is_active(libra_account.id, False)
stats["accounts_deactivated"] += 1
logger.info(
f"Deactivated orphaned account: {castle_account.name}"
f"Deactivated orphaned account: {libra_account.name}"
)
except Exception as e:
error_msg = (
f"Failed to deactivate account {castle_account.name}: {e}"
f"Failed to deactivate account {libra_account.name}: {e}"
)
logger.error(error_msg)
stats["errors"].append(error_msg)
@ -236,8 +236,8 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
# IMPORTANT: Re-fetch accounts from DB after Step 1 added new accounts
# Otherwise we'll be checking against stale data and miss newly synced children
current_castle_accounts = await get_all_accounts(include_inactive=True)
all_account_names = {acc.name for acc in current_castle_accounts}
current_libra_accounts = await get_all_accounts(include_inactive=True)
all_account_names = {acc.name for acc in current_libra_accounts}
for bc_account in beancount_accounts:
account_name = bc_account["account"]
@ -287,9 +287,9 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
async def sync_single_account_from_beancount(account_name: str) -> bool:
"""
Sync a single account from Beancount to Castle DB.
Sync a single account from Beancount to Libra DB.
Useful for ensuring a specific account exists in Castle DB before
Useful for ensuring a specific account exists in Libra DB before
granting permissions on it.
Args:
@ -318,13 +318,22 @@ async def sync_single_account_from_beancount(account_name: str) -> bool:
logger.error(f"Account not found in Beancount: {account_name}")
return False
# Create in Castle DB
# Create in Libra DB
account_type = infer_account_type_from_name(account_name)
user_id = extract_user_id_from_account_name(account_name)
# Prefer the full user_id stored in Beancount metadata (libra writes it
# when crud.get_or_create_user_account calls fava.add_account). Fall
# back to the name-derived 8-char prefix for accounts imported without
# metadata. This keeps user_id consistent with what the caller will
# query for, avoiding a churn cycle through the UNIQUE-constraint
# recovery path in crud.py.
description = None
meta_user_id = None
if "meta" in bc_account and isinstance(bc_account["meta"], dict):
description = bc_account["meta"].get("description")
meta_user_id = bc_account["meta"].get("user_id")
user_id = meta_user_id or extract_user_id_from_account_name(account_name)
await create_account(
CreateAccount(
@ -343,9 +352,9 @@ async def sync_single_account_from_beancount(account_name: str) -> bool:
return False
async def ensure_account_exists_in_castle(account_name: str) -> bool:
async def ensure_account_exists_in_libra(account_name: str) -> bool:
"""
Ensure account exists in Castle DB, creating from Beancount if needed.
Ensure account exists in Libra DB, creating from Beancount if needed.
This is the recommended function to call before granting permissions.
@ -355,7 +364,7 @@ async def ensure_account_exists_in_castle(account_name: str) -> bool:
Returns:
True if account exists (or was created), False if failed
"""
# Check Castle DB first
# Check Libra DB first
existing = await get_account_by_name(account_name)
if existing:
return True
@ -367,9 +376,9 @@ async def ensure_account_exists_in_castle(account_name: str) -> bool:
# Background sync task (can be scheduled with cron or async scheduler)
async def scheduled_account_sync():
"""
Scheduled task to sync accounts from Beancount to Castle DB.
Scheduled task to sync accounts from Beancount to Libra DB.
Run this periodically (e.g., every hour) to keep Castle DB in sync with Beancount.
Run this periodically (e.g., every hour) to keep Libra DB in sync with Beancount.
Example with APScheduler:
from apscheduler.schedulers.asyncio import AsyncIOScheduler

View file

@ -200,11 +200,11 @@ DEFAULT_HIERARCHICAL_ACCOUNTS = [
("Assets:FixedAssets:ProductionFacility", AccountType.ASSET, "Production facilities"),
("Assets:Inventory", AccountType.ASSET, "Inventory and stock"),
("Assets:Livestock", AccountType.ASSET, "Livestock and animals"),
("Assets:Receivable", AccountType.ASSET, "Money owed to the Castle"),
("Assets:Receivable", AccountType.ASSET, "Money owed to the organization"),
("Assets:Tools", AccountType.ASSET, "Tools and hand equipment"),
# Liabilities
("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the Castle"),
("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the organization"),
# Equity - User equity accounts created dynamically as Equity:User-{user_id}
# No parent "Equity" account needed - hierarchy is implicit in the name

View file

@ -1,5 +1,5 @@
"""
Centralized Authorization Module for Castle Extension.
Centralized Authorization Module for Libra Extension.
Provides consistent, secure authorization patterns across all endpoints.
@ -55,9 +55,9 @@ class AuthContext:
@property
def is_admin(self) -> bool:
"""
Check if user is a Castle admin (super user).
Check if user is a Libra admin (super user).
Note: In Castle, admin = super_user. There's no separate admin concept.
Note: In Libra, admin = super_user. There's no separate admin concept.
"""
return self.is_super_user
@ -130,7 +130,7 @@ async def require_super_user(
Require super user access.
Raises HTTPException 403 if not super user.
Use for Castle admin operations.
Use for Libra admin operations.
"""
auth = _build_auth_context(wallet)
if not auth.is_super_user:

View file

@ -1,8 +1,8 @@
"""
Format Castle entries as Beancount transactions for Fava API.
Format Libra entries as Beancount transactions for Fava API.
All entries submitted to Fava must follow Beancount syntax.
This module converts Castle data models to Fava API format.
This module converts Libra data models to Fava API format.
Key concepts:
- Amounts are strings: "200000 SATS" or "100.00 EUR"
@ -35,8 +35,8 @@ def sanitize_link(text: str) -> str:
'Test-pending'
>>> sanitize_link("Invoice #123")
'Invoice-123'
>>> sanitize_link("castle-abc123")
'castle-abc123'
>>> sanitize_link("libra-abc123")
'libra-abc123'
"""
# Replace any character that's not alphanumeric, dash, underscore, slash, or period with a hyphen
sanitized = re.sub(r'[^A-Za-z0-9\-_/.]', '-', text)
@ -67,7 +67,7 @@ def format_transaction(
postings: List of posting dicts (formatted by format_posting)
payee: Optional payee
tags: Optional tags (e.g., ["expense-entry", "approved"])
links: Optional links (e.g., ["castle-abc123", "^invoice-xyz"])
links: Optional links (e.g., ["libra-abc123", "^invoice-xyz"])
meta: Optional transaction metadata
Returns:
@ -93,8 +93,8 @@ def format_transaction(
)
],
tags=["expense-entry"],
links=["castle-abc123"],
meta={"user-id": "abc123", "source": "castle-expense-entry"}
links=["libra-abc123"],
meta={"user-id": "abc123", "source": "libra-expense-entry"}
)
"""
return {
@ -150,7 +150,7 @@ def format_posting_with_cost(
"""
Format a posting with cost basis for Fava API.
This is the RECOMMENDED format for all Castle transactions.
This is the RECOMMENDED format for all Libra transactions.
Uses Beancount's cost basis syntax to preserve exchange rates.
IMPORTANT: Beancount cost syntax uses PER-UNIT cost, not total cost.
@ -381,7 +381,7 @@ def format_expense_entry(
# Build entry metadata
entry_meta = {
"user-id": user_id,
"source": "castle-api",
"source": "libra-api",
"entry-id": entry_id
}
@ -419,7 +419,7 @@ def format_receivable_entry(
entry_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Format a receivable entry (user owes castle).
Format a receivable entry (user owes libra).
Uses price notation (@@ SATS) for BQL-queryable SATS tracking.
Generates unique receivable link (^rcv-{entry_id}) for settlement tracking.
@ -466,7 +466,7 @@ def format_receivable_entry(
entry_meta = {
"user-id": user_id,
"source": "castle-api",
"source": "libra-api",
"entry-id": entry_id
}
@ -512,7 +512,7 @@ def format_payment_entry(
amount_sats: Amount in satoshis (unsigned)
description: Payment description
entry_date: Date of payment
is_payable: True if castle paying user (payable), False if user paying castle (receivable)
is_payable: True if libra paying user (payable), False if user paying libra (receivable)
fiat_currency: Optional fiat currency
fiat_amount: Optional fiat amount (unsigned)
payment_hash: Lightning payment hash
@ -531,7 +531,7 @@ def format_payment_entry(
# Example: 908.44 EUR / 996896 SATS = 0.000911268 EUR/SAT (matches original receivable rate)
if fiat_currency and fiat_amount_abs and amount_sats_abs > 0:
if is_payable:
# Castle paying user: DR Payable, CR Lightning
# Libra paying user: DR Payable, CR Lightning
postings = [
format_posting_with_cost(
account=payable_or_receivable_account,
@ -546,7 +546,7 @@ def format_payment_entry(
)
]
else:
# User paying castle: DR Lightning, CR Receivable
# User paying libra: DR Lightning, CR Receivable
postings = [
format_posting_simple(
account=payment_account,
@ -633,7 +633,7 @@ def format_fiat_settlement_entry(
amount_sats: Equivalent amount in satoshis
description: Payment description
entry_date: Date of settlement
is_payable: True if castle paying user (payable), False if user paying castle (receivable)
is_payable: True if libra paying user (payable), False if user paying libra (receivable)
payment_method: Payment method (cash, bank_transfer, check, etc.)
reference: Optional reference
settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "exp-def456"])
@ -646,7 +646,7 @@ def format_fiat_settlement_entry(
# Build postings using price notation (@@ SATS) for BQL queryability
if is_payable:
# Castle paying user: DR Payable, CR Cash/Bank
# Libra paying user: DR Payable, CR Cash/Bank
postings = [
{
"account": payable_or_receivable_account,
@ -658,7 +658,7 @@ def format_fiat_settlement_entry(
}
]
else:
# User paying castle: DR Cash/Bank, CR Receivable
# User paying libra: DR Cash/Bank, CR Receivable
postings = [
{
"account": payment_account,
@ -804,6 +804,139 @@ def format_net_settlement_entry(
)
def format_fiat_net_settlement_entry(
user_id: str,
cash_account: str,
receivable_account: str,
payable_account: Optional[str],
credit_account: Optional[str],
cash_paid_fiat: Decimal,
total_receivable_fiat: Decimal,
total_payable_fiat: Decimal,
credit_overflow_fiat: Decimal,
fiat_currency: str,
description: str,
entry_date: date,
payment_method: str = "cash",
reference: Optional[str] = None,
settled_entry_links: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Fiat cash settlement that nets receivable and payable for one user.
Implements the contract from libra-#33 (settlement netting) and
libra-#41 (credit-balance overflow). Builds a 2- to 4-leg transaction
depending on what the user has open:
- Cash + Receivable only (2-leg) pure receivable, exact pay
- Cash + Receivable + Credit (3-leg) overpay against pure receivable
- Cash + Receivable + Payable (3-leg) nancy's #33 scenario, exact pay
- Cash + Receivable + Payable + Credit (4-leg) net + overpay
The receivable leg is always present (this endpoint is `/receivables/settle`).
The payable leg appears when the user has open expenses being netted against
the receivable. The credit leg appears when cash > settle target, absorbing
the overflow as a liability libra owes the user going forward.
Constraint enforced inline:
cash_paid_fiat = total_receivable_fiat - total_payable_fiat + credit_overflow_fiat
Args:
user_id: User ID
cash_account: Payment-method account name (e.g. "Assets:Cash")
receivable_account: User's receivable account being cleared
payable_account: User's payable account being cleared (omit when no payable)
credit_account: User's credit account receiving overflow (omit when no overflow)
cash_paid_fiat: What the user paid in cash, unsigned
total_receivable_fiat: Gross receivable being cleared (unsigned, 0 if none)
total_payable_fiat: Gross payable being cleared (unsigned, 0 if none)
credit_overflow_fiat: Excess cash going to credit (unsigned, 0 if none)
fiat_currency: Currency code (EUR, USD, etc.)
description: Entry narration
entry_date: Date of settlement
payment_method: cash / bank_transfer / check / other
reference: Optional caller-supplied reference (becomes an extra link)
settled_entry_links: Source entry links being cleared
(e.g. `["exp-abc", "rcv-def"]`). The audit trail for which
originals this settlement reconciles.
Returns:
Fava API entry dict ready for `fava.add_entry`.
Raises:
ValueError: if any amount is negative, or if the cash-balance
constraint above is not satisfied.
"""
for label, value in (
("cash_paid_fiat", cash_paid_fiat),
("total_receivable_fiat", total_receivable_fiat),
("total_payable_fiat", total_payable_fiat),
("credit_overflow_fiat", credit_overflow_fiat),
):
if value < 0:
raise ValueError(f"{label} must be non-negative; got {value}")
expected_cash = total_receivable_fiat - total_payable_fiat + credit_overflow_fiat
if abs(cash_paid_fiat - expected_cash) > Decimal("0.01"):
raise ValueError(
f"cash_paid_fiat {cash_paid_fiat} does not match expected "
f"{expected_cash} (= receivable {total_receivable_fiat} "
f"- payable {total_payable_fiat} + credit {credit_overflow_fiat})"
)
if total_payable_fiat > 0 and not payable_account:
raise ValueError("payable_account required when total_payable_fiat > 0")
if credit_overflow_fiat > 0 and not credit_account:
raise ValueError("credit_account required when credit_overflow_fiat > 0")
postings: List[Dict[str, Any]] = [
{"account": cash_account, "amount": f"{cash_paid_fiat:.2f} {fiat_currency}"},
{"account": receivable_account, "amount": f"-{total_receivable_fiat:.2f} {fiat_currency}"},
]
if total_payable_fiat > 0:
postings.append({
"account": payable_account,
"amount": f"{total_payable_fiat:.2f} {fiat_currency}",
})
if credit_overflow_fiat > 0:
postings.append({
"account": credit_account,
"amount": f"-{credit_overflow_fiat:.2f} {fiat_currency}",
})
payment_method_map = {
"cash": ("cash_settlement", "cash-payment"),
"bank_transfer": ("bank_settlement", "bank-transfer"),
"check": ("check_settlement", "check-payment"),
"btc_onchain": ("onchain_settlement", "onchain-payment"),
"other": ("manual_settlement", "manual-payment"),
}
source, tag = payment_method_map.get(
payment_method.lower(), ("manual_settlement", "manual-payment"),
)
entry_meta: Dict[str, Any] = {
"user-id": user_id,
"source": source,
"payment-type": "net-settlement",
}
links: List[str] = []
if settled_entry_links:
links.extend(settled_entry_links)
if reference:
links.append(sanitize_link(reference))
return format_transaction(
date_val=entry_date,
flag="*",
narration=description,
postings=postings,
tags=[tag, "settlement", "net-settlement"],
links=links,
meta=entry_meta,
)
def format_revenue_entry(
payment_account: str,
revenue_account: str,
@ -812,10 +945,11 @@ def format_revenue_entry(
entry_date: date,
fiat_currency: Optional[str] = None,
fiat_amount: Optional[Decimal] = None,
reference: Optional[str] = None
reference: Optional[str] = None,
entry_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Format a revenue entry (castle receives payment directly).
Format a revenue entry (libra receives payment directly).
Creates a cleared transaction (flag="*") since payment was received.
@ -829,7 +963,8 @@ def format_revenue_entry(
entry_date: Date of payment
fiat_currency: Optional fiat currency
fiat_amount: Optional fiat amount (unsigned)
reference: Optional reference
reference: Optional reference (invoice ID, etc.) stored as its own link
entry_id: Optional unique entry ID (generated if not provided)
Returns:
Fava API entry dict
@ -845,6 +980,9 @@ def format_revenue_entry(
fiat_amount=Decimal("50.00")
)
"""
if not entry_id:
entry_id = generate_entry_id()
amount_sats_abs = abs(amount_sats)
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
@ -869,12 +1007,13 @@ def format_revenue_entry(
# Note: created-via is redundant with #revenue-entry tag
entry_meta = {
"source": "castle-api"
"source": "libra-api",
"entry-id": entry_id
}
links = []
if reference:
links.append(reference)
links.append(sanitize_link(reference))
return format_transaction(
date_val=entry_date,
@ -885,3 +1024,68 @@ def format_revenue_entry(
links=links,
meta=entry_meta
)
def format_income_entry(
user_id: str,
user_account: str,
revenue_account: str,
amount_sats: int,
description: str,
entry_date: date,
fiat_currency: str,
fiat_amount: Decimal,
reference: Optional[str] = None,
entry_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
Format a user-submitted income/revenue entry for Fava (pending approval).
Mirrors format_expense_entry: pending flag (!) for super-user review,
fiat-first price notation (@@ SATS) for BQL queryability, unique link
(^inc-{entry_id}) for tracking through the approve/reject flow.
Postings: DR user_account (Assets:Receivable:User-{id} user owes
the entity until they hand the cash over), CR revenue_account.
"""
if not fiat_currency or not fiat_amount or fiat_amount <= 0:
raise ValueError("fiat_currency and a positive fiat_amount are required for income entries")
if not entry_id:
entry_id = generate_entry_id()
fiat_amount_abs = abs(fiat_amount)
sats_abs = abs(amount_sats)
narration = f"{description} ({fiat_amount_abs:.2f} {fiat_currency})"
postings = [
{
"account": user_account,
"amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS",
},
{
"account": revenue_account,
"amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS",
},
]
entry_meta = {
"user-id": user_id,
"source": "libra-api",
"entry-id": entry_id,
}
links = [f"inc-{entry_id}"]
if reference:
links.append(sanitize_link(reference))
return format_transaction(
date_val=entry_date,
flag="!", # Pending - requires admin approval
narration=narration,
postings=postings,
tags=["income-entry"],
links=links,
meta=entry_meta,
)

View file

@ -1,11 +1,11 @@
{
"name": "Castle Accounting",
"name": "Libra",
"short_description": "Double-entry accounting system for collective projects",
"tile": "/castle/static/image/castle.png",
"tile": "/libra/static/image/libra.png",
"contributors": [
"Your Name"
],
"hidden": false,
"migration_module": "lnbits.extensions.castle.migrations",
"db_name": "ext_castle"
"migration_module": "lnbits.extensions.libra.migrations",
"db_name": "ext_libra"
}

View file

@ -1,5 +1,5 @@
"""
Castle Core Module - Pure accounting logic separated from database operations.
Libra Core Module - Pure accounting logic separated from database operations.
This module contains the core business logic for double-entry accounting,
following Beancount patterns for clean architecture:

View file

@ -1,5 +1,5 @@
"""
Validation rules for Castle accounting.
Validation rules for Libra accounting.
Comprehensive validation following Beancount's plugin system approach,
but implemented as simple functions that can be called directly.
@ -159,7 +159,7 @@ def validate_receivable_entry(
revenue_account_type: str
) -> None:
"""
Validate a receivable entry (user owes castle).
Validate a receivable entry (user owes libra).
Args:
user_id: User ID

90
crud.py
View file

@ -14,7 +14,7 @@ from .models import (
AssertionStatus,
AssignUserRole,
BalanceAssertion,
CastleSettings,
LibraSettings,
CreateAccount,
CreateAccountPermission,
CreateBalanceAssertion,
@ -32,7 +32,7 @@ from .models import (
StoredUserWalletSettings,
UpdateRole,
UserBalance,
UserCastleSettings,
UserLibraSettings,
UserEquityStatus,
UserRole,
UserWalletSettings,
@ -49,7 +49,7 @@ from .core.validation import (
validate_payment_entry,
)
db = Database("ext_castle")
db = Database("ext_libra")
# ===== CACHING =====
# Cache for account and permission lookups to reduce DB queries
@ -197,7 +197,7 @@ async def get_or_create_user_account(
Get or create a user-specific account with hierarchical naming.
This function checks if the account exists in Fava/Beancount and creates it
if it doesn't exist. The account is also registered in Castle's database for
if it doesn't exist. The account is also registered in Libra's database for
metadata tracking (permissions, descriptions, etc.).
Examples:
@ -214,7 +214,7 @@ async def get_or_create_user_account(
# Generate hierarchical account name
account_name = format_hierarchical_account_name(account_type, base_name, user_id)
# Try to find existing account with this hierarchical name in Castle DB
# Try to find existing account with this hierarchical name in Libra DB
account = await db.fetchone(
"""
SELECT * FROM accounts
@ -224,9 +224,9 @@ async def get_or_create_user_account(
Account,
)
logger.info(f"[ACCOUNT CHECK] User {user_id[:8]}, Account: {account_name}, In Castle DB: {account is not None}")
logger.info(f"[ACCOUNT CHECK] User {user_id[:8]}, Account: {account_name}, In Libra DB: {account is not None}")
# Always check/create in Fava, even if account exists in Castle DB
# Always check/create in Fava, even if account exists in Libra DB
# This ensures Beancount has the Open directive
fava_account_exists = False
if True: # Always check Fava
@ -250,9 +250,13 @@ async def get_or_create_user_account(
if not fava_account_exists:
# Create account in Fava/Beancount via Open directive
logger.info(f"[FAVA CREATE] Creating account in Fava: {account_name}")
# Unconstrained Open: a per-user receivable/payable legitimately
# holds arbitrary fiat (CAD/GBP/JPY/…). Constraining it to
# EUR/SATS/USD made any posting in another currency fail
# bean-check (the errors this account path originally exhibited).
await fava.add_account(
account_name=account_name,
currencies=["EUR", "SATS", "USD"], # Support common currencies
currencies=None,
metadata={
"user_id": user_id,
"description": f"User-specific {account_type.value} account"
@ -262,23 +266,23 @@ async def get_or_create_user_account(
except Exception as e:
logger.error(f"[FAVA ERROR] Could not check/create account in Fava: {e}", exc_info=True)
# Continue anyway - account creation in Castle DB is still useful for metadata
# Continue anyway - account creation in Libra DB is still useful for metadata
# Ensure account exists in Castle DB (sync from Beancount if needed)
# Ensure account exists in Libra DB (sync from Beancount if needed)
# This uses the account sync module for consistency
if not account:
logger.info(f"[CASTLE DB] Syncing account from Beancount to Castle DB: {account_name}")
logger.info(f"[LIBRA DB] Syncing account from Beancount to Libra DB: {account_name}")
from .account_sync import sync_single_account_from_beancount
# Sync from Beancount to Castle DB
# Sync from Beancount to Libra DB
created = await sync_single_account_from_beancount(account_name)
if created:
logger.info(f"[CASTLE DB] Account synced from Beancount: {account_name}")
logger.info(f"[LIBRA DB] Account synced from Beancount: {account_name}")
else:
logger.warning(f"[CASTLE DB] Failed to sync account from Beancount: {account_name}")
logger.warning(f"[LIBRA DB] Failed to sync account from Beancount: {account_name}")
# Fetch the account from Castle DB
# Fetch the account from Libra DB
account = await db.fetchone(
"""
SELECT * FROM accounts
@ -289,9 +293,9 @@ async def get_or_create_user_account(
)
if not account:
logger.error(f"[CASTLE DB] Account still not found after sync: {account_name}")
# Fallback: create directly in Castle DB if sync failed
logger.info(f"[CASTLE DB] Creating account directly in Castle DB: {account_name}")
logger.error(f"[LIBRA DB] Account still not found after sync: {account_name}")
# Fallback: create directly in Libra DB if sync failed
logger.info(f"[LIBRA DB] Creating account directly in Libra DB: {account_name}")
try:
account = await create_account(
CreateAccount(
@ -304,7 +308,7 @@ async def get_or_create_user_account(
except Exception as e:
# Handle UNIQUE constraint error - account already exists
if "UNIQUE constraint failed" in str(e) and "accounts.name" in str(e):
logger.warning(f"[CASTLE DB] Account already exists (UNIQUE constraint), fetching by name: {account_name}")
logger.warning(f"[LIBRA DB] Account already exists (UNIQUE constraint), fetching by name: {account_name}")
# Fetch existing account by name only (ignore user_id in query)
account = await db.fetchone(
"""
@ -315,10 +319,10 @@ async def get_or_create_user_account(
Account,
)
if account:
logger.info(f"[CASTLE DB] Found existing account: {account_name} (user_id: {account.user_id})")
logger.info(f"[LIBRA DB] Found existing account: {account_name} (user_id: {account.user_id})")
# Update user_id if it's NULL or different
if account.user_id != user_id:
logger.info(f"[CASTLE DB] Updating account user_id from {account.user_id} to {user_id}")
logger.info(f"[LIBRA DB] Updating account user_id from {account.user_id} to {user_id}")
await db.execute(
"""
UPDATE accounts
@ -340,7 +344,7 @@ async def get_or_create_user_account(
# Re-raise if it's a different error
raise
else:
logger.info(f"[CASTLE DB] Account already exists in Castle DB: {account_name}")
logger.info(f"[LIBRA DB] Account already exists in Libra DB: {account_name}")
return account
@ -351,7 +355,7 @@ async def get_or_create_user_account(
# ===== JOURNAL ENTRY OPERATIONS (REMOVED) =====
#
# All journal entry operations have been moved to Fava/Beancount.
# Castle no longer maintains its own journal_entries and entry_lines tables.
# Libra no longer maintains its own journal_entries and entry_lines tables.
#
# For journal entry operations, see:
# - views_api.py: api_create_journal_entry() - writes to Fava via FavaClient
@ -375,29 +379,29 @@ async def get_or_create_user_account(
# ===== SETTINGS =====
async def create_castle_settings(
user_id: str, data: CastleSettings
) -> CastleSettings:
settings = UserCastleSettings(**data.dict(), id=user_id)
async def create_libra_settings(
user_id: str, data: LibraSettings
) -> LibraSettings:
settings = UserLibraSettings(**data.dict(), id=user_id)
await db.insert("extension_settings", settings)
return settings
async def get_castle_settings(user_id: str) -> Optional[CastleSettings]:
async def get_libra_settings(user_id: str) -> Optional[LibraSettings]:
return await db.fetchone(
"""
SELECT * FROM extension_settings
WHERE id = :user_id
""",
{"user_id": user_id},
CastleSettings,
LibraSettings,
)
async def update_castle_settings(
user_id: str, data: CastleSettings
) -> CastleSettings:
settings = UserCastleSettings(**data.dict(), id=user_id)
async def update_libra_settings(
user_id: str, data: LibraSettings
) -> LibraSettings:
settings = UserLibraSettings(**data.dict(), id=user_id)
await db.update("extension_settings", settings)
return settings
@ -424,6 +428,26 @@ async def get_user_wallet_settings(user_id: str) -> Optional[UserWalletSettings]
)
async def get_user_wallet_settings_by_prefix(
user_id_prefix: str,
) -> Optional[StoredUserWalletSettings]:
"""
Get user wallet settings by user ID prefix (for truncated 8-char IDs from Beancount).
Beancount accounts use truncated user IDs (first 8 chars), but the database
stores full UUIDs. This function looks up by prefix to bridge the gap.
"""
return await db.fetchone(
"""
SELECT * FROM user_wallet_settings
WHERE id LIKE :prefix || '%'
LIMIT 1
""",
{"prefix": user_id_prefix},
StoredUserWalletSettings,
)
async def update_user_wallet_settings(
user_id: str, data: UserWalletSettings
) -> UserWalletSettings:

View file

@ -1,4 +1,4 @@
# Castle Accounting
# Libra
A comprehensive double-entry accounting system for collective projects, designed specifically for LNbits.
@ -7,29 +7,29 @@ A comprehensive double-entry accounting system for collective projects, designed
- **Double-Entry Bookkeeping**: Full accounting system with debits and credits
- **Chart of Accounts**: Pre-configured accounts for Assets, Liabilities, Equity, Revenue, and Expenses
- **User Expense Tracking**: Members can record out-of-pocket expenses as either:
- **Liabilities**: Castle owes them money (reimbursable)
- **Liabilities**: Libra owes them money (reimbursable)
- **Equity**: Their contribution to the collective
- **Accounts Receivable**: Track what users owe the Castle (e.g., accommodation fees)
- **Accounts Receivable**: Track what users owe the organization (e.g., accommodation fees)
- **Revenue Tracking**: Record revenue received by the collective
- **User Balance Dashboard**: Each user sees their balance with the Castle
- **User Balance Dashboard**: Each user sees their balance with the organization
- **Lightning Integration**: Generate invoices for outstanding balances
- **Transaction History**: View all accounting entries and transactions
## Use Cases
### 1. User Pays Expense Out of Pocket
When a member buys supplies for the Castle:
When a member buys supplies for the collective:
- They can choose to be reimbursed (Liability)
- Or contribute it as equity (Equity)
### 2. Accounts Receivable
When someone stays at the Castle and owes money:
When someone stays with the collective and owes money:
- Admin creates an AR entry (e.g., "5 nights @ 10€/night = 50€")
- User sees they owe 50€ in their dashboard
- They can generate an invoice to pay it off
### 3. Revenue Recording
When the Castle receives revenue:
When the organization receives revenue:
- Record revenue with the payment method (Cash, Lightning, Bank)
- Properly categorized in the accounting system
@ -58,8 +58,8 @@ When the Castle receives revenue:
## Getting Started
1. Enable the Castle extension in LNbits
2. Visit the Castle page to see your dashboard
1. Enable the Libra extension in LNbits
2. Visit the Libra page to see your dashboard
3. Start tracking expenses and balances!
The extension automatically creates a default chart of accounts on first run.

View file

@ -8,9 +8,9 @@
## Summary
Implemented two major improvements for Castle administration:
Implemented two major improvements for Libra administration:
1. **Account Synchronization** - Automatically sync accounts from Beancount → Castle DB
1. **Account Synchronization** - Automatically sync accounts from Beancount → Libra DB
2. **Bulk Permission Management** - Tools for managing permissions at scale
**Total Implementation Time**: ~4 hours
@ -23,24 +23,24 @@ Implemented two major improvements for Castle administration:
### Problem Solved
**Before**: Accounts existed in both Beancount and Castle DB, with manual sync required.
**After**: Automatic sync keeps Castle DB in sync with Beancount (source of truth).
**Before**: Accounts existed in both Beancount and Libra DB, with manual sync required.
**After**: Automatic sync keeps Libra DB in sync with Beancount (source of truth).
### Implementation
**New Module**: `castle/account_sync.py`
**New Module**: `libra/account_sync.py`
**Core Functions**:
```python
# 1. Full sync from Beancount to Castle
# 1. Full sync from Beancount to Libra
stats = await sync_accounts_from_beancount(force_full_sync=False)
# 2. Sync single account
success = await sync_single_account_from_beancount("Expenses:Food")
# 3. Ensure account exists (recommended before granting permissions)
exists = await ensure_account_exists_in_castle("Expenses:Marketing")
exists = await ensure_account_exists_in_libra("Expenses:Marketing")
# 4. Scheduled background sync (run hourly)
stats = await scheduled_account_sync()
@ -77,7 +77,7 @@ stats = await scheduled_account_sync()
```python
# Sync all accounts from Beancount
from castle.account_sync import sync_accounts_from_beancount
from libra.account_sync import sync_accounts_from_beancount
stats = await sync_accounts_from_beancount()
@ -96,11 +96,11 @@ Errors: 0
#### Before Granting Permission (Best Practice)
```python
from castle.account_sync import ensure_account_exists_in_castle
from castle.crud import create_account_permission
from libra.account_sync import ensure_account_exists_in_libra
from libra.crud import create_account_permission
# Ensure account exists in Castle DB first
account_exists = await ensure_account_exists_in_castle("Expenses:Marketing")
# Ensure account exists in Libra DB first
account_exists = await ensure_account_exists_in_libra("Expenses:Marketing")
if account_exists:
# Now safe to grant permission
@ -116,9 +116,9 @@ if account_exists:
```python
# Add to your scheduler (cron, APScheduler, etc.)
from castle.account_sync import scheduled_account_sync
from libra.account_sync import scheduled_account_sync
# Run every hour to keep Castle DB in sync
# Run every hour to keep Libra DB in sync
scheduler.add_job(
scheduled_account_sync,
'interval',
@ -142,7 +142,7 @@ Authorization: Bearer {admin_key}
```json
{
"total_beancount_accounts": 150,
"total_castle_accounts": 150,
"total_libra_accounts": 150,
"accounts_added": 2,
"accounts_updated": 0,
"accounts_skipped": 148,
@ -152,8 +152,8 @@ Authorization: Bearer {admin_key}
### Benefits
1. **Beancount as Source of Truth**: Castle DB automatically reflects Beancount state
2. **Reduced Manual Work**: No more manual account creation in Castle
1. **Beancount as Source of Truth**: Libra DB automatically reflects Beancount state
2. **Reduced Manual Work**: No more manual account creation in Libra
3. **Prevents Permission Errors**: Cannot grant permission on non-existent account
4. **Audit Trail**: Tracks which accounts were synced and when
5. **Safe Operations**: Continues on errors, never deletes accounts
@ -169,7 +169,7 @@ Authorization: Bearer {admin_key}
### Implementation
**New Module**: `castle/permission_management.py`
**New Module**: `libra/permission_management.py`
**Core Functions**:
@ -471,19 +471,19 @@ print(f"Permission types removed: {result['permission_types_removed']}")
# OLD: Manual permission creation (risky)
await create_account_permission(
user_id="alice",
account_id="acc123", # What if account doesn't exist in Castle DB?
account_id="acc123", # What if account doesn't exist in Libra DB?
permission_type=PermissionType.SUBMIT_EXPENSE,
granted_by="admin"
)
# NEW: Safe permission creation with account sync
from castle.account_sync import ensure_account_exists_in_castle
from libra.account_sync import ensure_account_exists_in_libra
# Ensure account exists first
account_exists = await ensure_account_exists_in_castle("Expenses:Marketing")
account_exists = await ensure_account_exists_in_libra("Expenses:Marketing")
if account_exists:
# Now safe - account guaranteed to be in Castle DB
# Now safe - account guaranteed to be in Libra DB
await create_account_permission(
user_id="alice",
account_id=account_id,
@ -497,10 +497,10 @@ else:
### Scheduler Integration
```python
# Add to your Castle extension startup
# Add to your Libra extension startup
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from castle.account_sync import scheduled_account_sync
from castle.permission_management import cleanup_expired_permissions
from libra.account_sync import scheduled_account_sync
from libra.permission_management import cleanup_expired_permissions
scheduler = AsyncIOScheduler()
@ -610,7 +610,7 @@ async def test_copy_permissions():
async def test_onboarding_workflow():
"""Test complete onboarding workflow"""
# 1. Sync account
await ensure_account_exists_in_castle("Expenses:Food")
await ensure_account_exists_in_libra("Expenses:Food")
# 2. Copy permissions from template user
result = await copy_permissions(
@ -745,19 +745,19 @@ logger.error(f"Account sync error: {error}")
## Migration Guide
### For Existing Castle Installations
### For Existing Libra Installations
**Step 1: Deploy New Modules**
```bash
# Copy new files to Castle extension
cp account_sync.py /path/to/castle/
cp permission_management.py /path/to/castle/
# Copy new files to Libra extension
cp account_sync.py /path/to/libra/
cp permission_management.py /path/to/libra/
```
**Step 2: Initial Account Sync**
```python
# Run once to sync existing accounts
from castle.account_sync import sync_accounts_from_beancount
from libra.account_sync import sync_accounts_from_beancount
stats = await sync_accounts_from_beancount(force_full_sync=True)
print(f"Synced {stats['accounts_added']} accounts")
@ -784,14 +784,14 @@ await bulk_grant_permission(...)
## Documentation Updates
**New files created**:
- ✅ `castle/account_sync.py` (230 lines)
- ✅ `castle/permission_management.py` (400 lines)
- ✅ `libra/account_sync.py` (230 lines)
- ✅ `libra/permission_management.py` (400 lines)
- ✅ `docs/PERMISSIONS-SYSTEM.md` (full permission system docs)
- ✅ `docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md` (this file)
**Files to update**:
- `castle/views_api.py` - Add new admin endpoints
- `castle/README.md` - Document new features
- `libra/views_api.py` - Add new admin endpoints
- `libra/README.md` - Document new features
- `tests/` - Add comprehensive tests
---
@ -801,7 +801,7 @@ await bulk_grant_permission(...)
### What Was Built
1. **Account Sync Module** (230 lines)
- Automatic sync from Beancount → Castle DB
- Automatic sync from Beancount → Libra DB
- Type inference and user ID extraction
- Background scheduling support

View file

@ -195,13 +195,13 @@ id="toc-professional-assessment">Professional Assessment</a></li>
<h1 id="accounting-analysis-net-settlement-entry-pattern">Accounting
Analysis: Net Settlement Entry Pattern</h1>
<p><strong>Date</strong>: 2025-01-12 <strong>Prepared By</strong>:
Senior Accounting Review <strong>Subject</strong>: Castle Extension -
Senior Accounting Review <strong>Subject</strong>: Libra Extension -
Lightning Payment Settlement Entries <strong>Status</strong>: Technical
Review</p>
<hr />
<h2 id="executive-summary">Executive Summary</h2>
<p>This document provides a professional accounting assessment of
Castles net settlement entry pattern used for recording Lightning
Libras net settlement entry pattern used for recording Lightning
Network payments that settle fiat-denominated receivables. The analysis
identifies areas where the implementation deviates from traditional
accounting best practices and provides specific recommendations for
@ -214,7 +214,7 @@ hierarchy</p>
<hr />
<h2 id="background-the-technical-challenge">Background: The Technical
Challenge</h2>
<p>Castle operates as a Lightning Network-integrated accounting system
<p>Libra operates as a Lightning Network-integrated accounting system
for collectives (co-living spaces, makerspaces). It faces a unique
accounting challenge:</p>
<p><strong>Scenario</strong>: User creates a receivable in EUR (e.g.,
@ -223,7 +223,7 @@ accounting challenge:</p>
<p><strong>Challenge</strong>: Record the payment while: 1. Clearing the
exact EUR receivable amount 2. Recording the exact satoshi amount
received 3. Handling cases where users have both receivables (owe
Castle) and payables (Castle owes them) 4. Maintaining Beancount
Libra) and payables (Libra owes them) 4. Maintaining Beancount
double-entry balance</p>
<hr />
<h2 id="current-implementation">Current Implementation</h2>
@ -231,7 +231,7 @@ double-entry balance</p>
<pre class="beancount"><code>; Step 1: Receivable Created
2025-11-12 * &quot;room (200.00 EUR)&quot; #receivable-entry
user-id: &quot;375ec158&quot;
source: &quot;castle-api&quot;
source: &quot;libra-api&quot;
sats-amount: &quot;225033&quot;
Assets:Receivable:User-375ec158 200.00 EUR
sats-equivalent: &quot;225033&quot;
@ -344,7 +344,7 @@ class="sourceCode sql"><code class="sourceCode sql"><span id="cb8-1"><a href="#c
payment-hash: &quot;8d080ec4...&quot;
Assets:Receivable:User-375ec158 -200.00 EUR
; No sats-equivalent needed here</code></pre>
<p><strong>Option B - Use EUR positions with metadata</strong> (Castles
<p><strong>Option B - Use EUR positions with metadata</strong> (Libras
current approach):</p>
<pre class="beancount"><code>Assets:Bitcoin:Lightning 200.00 EUR
sats-received: &quot;225033&quot;
@ -452,8 +452,8 @@ OR payable)</li>
(receivable AND payable)</li>
</ul>
<p><strong>When Net Settlement is Appropriate</strong>:</p>
<pre><code>User owes Castle: 555.00 EUR (receivable)
Castle owes User: 38.00 EUR (payable)
<pre><code>User owes Libra: 555.00 EUR (receivable)
Libra owes User: 38.00 EUR (payable)
Net amount due: 517.00 EUR (true settlement)</code></pre>
<p>Proper three-posting entry:</p>
<pre class="beancount"><code>Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR
@ -461,8 +461,8 @@ Assets:Receivable:User -555.00 EUR
Liabilities:Payable:User 38.00 EUR
; Net: 517.00 = -555.00 + 38.00 ✓</code></pre>
<p><strong>When Two Postings Suffice</strong>:</p>
<pre><code>User owes Castle: 200.00 EUR (receivable)
Castle owes User: 0.00 EUR (no payable)
<pre><code>User owes Libra: 200.00 EUR (receivable)
Libra owes User: 0.00 EUR (no payable)
Amount due: 200.00 EUR (simple payment)</code></pre>
<p>Simpler two-posting entry:</p>
<pre class="beancount"><code>Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR
@ -515,7 +515,7 @@ positions - ❌ Requires metadata parsing for SATS balances</p>
id="approach-3-true-net-settlement-when-both-obligations-exist">Approach
3: True Net Settlement (When Both Obligations Exist)</h3>
<pre class="beancount"><code>2025-11-12 * &quot;Net settlement via Lightning&quot;
; User owes 555 EUR, Castle owes 38 EUR, net: 517 EUR
; User owes 555 EUR, Libra owes 38 EUR, net: 517 EUR
Assets:Bitcoin:Lightning 517.00 EUR
sats-received: &quot;565251&quot;
Assets:Receivable:User-375ec158 -555.00 EUR
@ -570,7 +570,7 @@ Method</h4>
<p><strong>Decision Required</strong>: Select either position-based OR
metadata-based satoshi tracking.</p>
<p><strong>Option A - Keep Metadata Approach</strong> (recommended for
Castle):</p>
Libra):</p>
<div class="sourceCode" id="cb25"><pre
class="sourceCode python"><code class="sourceCode python"><span id="cb25-1"><a href="#cb25-1" aria-hidden="true" tabindex="-1"></a><span class="co"># In format_net_settlement_entry()</span></span>
<span id="cb25-2"><a href="#cb25-2" aria-hidden="true" tabindex="-1"></a>postings <span class="op">=</span> [</span>
@ -604,7 +604,7 @@ class="sourceCode python"><code class="sourceCode python"><span id="cb26-1"><a h
<span id="cb26-12"><a href="#cb26-12" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb26-13"><a href="#cb26-13" aria-hidden="true" tabindex="-1"></a>]</span></code></pre></div>
<p><strong>Recommendation</strong>: Choose Option A (metadata) for
consistency with Castles architecture.</p>
consistency with Libras architecture.</p>
<hr />
<h4 id="rename-function-for-clarity">1.3 Rename Function for
Clarity</h4>
@ -713,7 +713,7 @@ class="sourceCode python"><code class="sourceCode python"><span id="cb28-1"><a h
<span id="cb28-45"><a href="#cb28-45" aria-hidden="true" tabindex="-1"></a> payment_hash<span class="op">=</span>payment_hash</span>
<span id="cb28-46"><a href="#cb28-46" aria-hidden="true" tabindex="-1"></a> )</span>
<span id="cb28-47"><a href="#cb28-47" aria-hidden="true" tabindex="-1"></a> <span class="cf">else</span>:</span>
<span id="cb28-48"><a href="#cb28-48" aria-hidden="true" tabindex="-1"></a> <span class="co"># PAYABLE PAYMENT: Castle paying user (different flow)</span></span>
<span id="cb28-48"><a href="#cb28-48" aria-hidden="true" tabindex="-1"></a> <span class="co"># PAYABLE PAYMENT: Libra paying user (different flow)</span></span>
<span id="cb28-49"><a href="#cb28-49" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span> <span class="cf">await</span> format_payable_payment_entry(...)</span></code></pre></div>
<hr />
<h3 id="priority-3-long-term-architectural-decisions">Priority 3:
@ -742,7 +742,7 @@ architectures:</p>
<p><strong>Recommendation</strong>: Architecture A (EUR primary)
because: 1. Most receivables created in EUR 2. Financial reporting
requirements typically in fiat 3. Tax obligations calculated in fiat 4.
Aligns with current Castle metadata approach</p>
Aligns with current Libra metadata approach</p>
<hr />
<h4 id="consider-separate-ledger-for-cryptocurrency-holdings">3.2
Consider Separate Ledger for Cryptocurrency Holdings</h4>
@ -754,7 +754,7 @@ from fiat accounting</p>
Assets:Receivable:User-375ec158 -200.00 EUR</code></pre>
<p><strong>Cryptocurrency Sub-Ledger</strong> (SATS-denominated):</p>
<pre class="beancount"><code>2025-11-12 * &quot;Lightning payment received&quot;
Assets:Bitcoin:Lightning:Castle 225033 SATS
Assets:Bitcoin:Lightning:Libra 225033 SATS
Assets:Bitcoin:Custody:User-375ec 225033 SATS</code></pre>
<p><strong>Benefits</strong>: - ✅ Clean separation of concerns - ✅
Cryptocurrency movements tracked independently - ✅ Fiat accounting
@ -902,7 +902,7 @@ Entry balances</p>
<p><strong>Is this “best practice” accounting?</strong>
<strong>No</strong>, this implementation deviates from traditional
accounting standards in several ways.</p>
<p><strong>Is it acceptable for Castles use case?</strong> <strong>Yes,
<p><strong>Is it acceptable for Libras use case?</strong> <strong>Yes,
with modifications</strong>, its a reasonable pragmatic solution for a
novel problem (cryptocurrency payments of fiat debts).</p>
<p><strong>Critical improvements needed</strong>: 1. ✅ Remove
@ -912,7 +912,7 @@ Separate payment vs. settlement logic (accuracy and clarity)</p>
<p><strong>The fundamental challenge</strong>: Traditional accounting
wasnt designed for this scenario. There is no established “standard”
for recording cryptocurrency payments of fiat-denominated receivables.
Castles approach is functional, but should be refined to align better
Libras approach is functional, but should be refined to align better
with accounting principles where possible.</p>
<h3 id="next-steps">Next Steps</h3>
<ol type="1">
@ -935,7 +935,7 @@ Characteristics of Accounting Information</li>
<li><strong>ASC 105-10-05</strong>: Substance Over Form</li>
<li><strong>Beancount Documentation</strong>:
http://furius.ca/beancount/doc/index</li>
<li><strong>Castle Extension</strong>:
<li><strong>Libra Extension</strong>:
<code>docs/SATS-EQUIVALENT-METADATA.md</code></li>
<li><strong>BQL Analysis</strong>:
<code>docs/BQL-BALANCE-QUERIES.md</code></li>
@ -948,6 +948,6 @@ implemented</p>
<p><em>This analysis was prepared for internal review and development
planning. It represents a professional accounting assessment of the
current implementation and should be used to guide improvements to
Castles payment recording system.</em></p>
Libras payment recording system.</em></p>
</body>
</html>

View file

@ -2,14 +2,14 @@
**Date**: 2025-01-12
**Prepared By**: Senior Accounting Review
**Subject**: Castle Extension - Lightning Payment Settlement Entries
**Subject**: Libra Extension - Lightning Payment Settlement Entries
**Status**: Technical Review
---
## Executive Summary
This document provides a professional accounting assessment of Castle's net settlement entry pattern used for recording Lightning Network payments that settle fiat-denominated receivables. The analysis identifies areas where the implementation deviates from traditional accounting best practices and provides specific recommendations for improvement.
This document provides a professional accounting assessment of Libra's net settlement entry pattern used for recording Lightning Network payments that settle fiat-denominated receivables. The analysis identifies areas where the implementation deviates from traditional accounting best practices and provides specific recommendations for improvement.
**Key Findings**:
- ✅ Double-entry integrity maintained
@ -23,14 +23,14 @@ This document provides a professional accounting assessment of Castle's net sett
## Background: The Technical Challenge
Castle operates as a Lightning Network-integrated accounting system for collectives (co-living spaces, makerspaces). It faces a unique accounting challenge:
Libra operates as a Lightning Network-integrated accounting system for collectives (co-living spaces, makerspaces). It faces a unique accounting challenge:
**Scenario**: User creates a receivable in EUR (e.g., €200 for room rent), then pays via Lightning Network in satoshis (225,033 sats).
**Challenge**: Record the payment while:
1. Clearing the exact EUR receivable amount
2. Recording the exact satoshi amount received
3. Handling cases where users have both receivables (owe Castle) and payables (Castle owes them)
3. Handling cases where users have both receivables (owe Libra) and payables (Libra owes them)
4. Maintaining Beancount double-entry balance
---
@ -43,7 +43,7 @@ Castle operates as a Lightning Network-integrated accounting system for collecti
; Step 1: Receivable Created
2025-11-12 * "room (200.00 EUR)" #receivable-entry
user-id: "375ec158"
source: "castle-api"
source: "libra-api"
sats-amount: "225033"
Assets:Receivable:User-375ec158 200.00 EUR
sats-equivalent: "225033"
@ -187,7 +187,7 @@ Assets:Receivable:User-375ec158 -200.00 EUR
; No sats-equivalent needed here
```
**Option B - Use EUR positions with metadata** (Castle's current approach):
**Option B - Use EUR positions with metadata** (Libra's current approach):
```beancount
Assets:Bitcoin:Lightning 200.00 EUR
sats-received: "225033"
@ -314,8 +314,8 @@ Assets:Receivable:User-375ec158 -200.00 EUR
**When Net Settlement is Appropriate**:
```
User owes Castle: 555.00 EUR (receivable)
Castle owes User: 38.00 EUR (payable)
User owes Libra: 555.00 EUR (receivable)
Libra owes User: 38.00 EUR (payable)
Net amount due: 517.00 EUR (true settlement)
```
@ -330,8 +330,8 @@ Liabilities:Payable:User 38.00 EUR
**When Two Postings Suffice**:
```
User owes Castle: 200.00 EUR (receivable)
Castle owes User: 0.00 EUR (no payable)
User owes Libra: 200.00 EUR (receivable)
Libra owes User: 0.00 EUR (no payable)
Amount due: 200.00 EUR (simple payment)
```
@ -405,7 +405,7 @@ Assets:Receivable:User -200.00 EUR
```beancount
2025-11-12 * "Net settlement via Lightning"
; User owes 555 EUR, Castle owes 38 EUR, net: 517 EUR
; User owes 555 EUR, Libra owes 38 EUR, net: 517 EUR
Assets:Bitcoin:Lightning 517.00 EUR
sats-received: "565251"
Assets:Receivable:User-375ec158 -555.00 EUR
@ -469,7 +469,7 @@ if total_payable_fiat > 0:
**Decision Required**: Select either position-based OR metadata-based satoshi tracking.
**Option A - Keep Metadata Approach** (recommended for Castle):
**Option A - Keep Metadata Approach** (recommended for Libra):
```python
# In format_net_settlement_entry()
postings = [
@ -506,7 +506,7 @@ postings = [
]
```
**Recommendation**: Choose Option A (metadata) for consistency with Castle's architecture.
**Recommendation**: Choose Option A (metadata) for consistency with Libra's architecture.
---
@ -625,7 +625,7 @@ async def create_payment_entry(
payment_hash=payment_hash
)
else:
# PAYABLE PAYMENT: Castle paying user (different flow)
# PAYABLE PAYMENT: Libra paying user (different flow)
return await format_payable_payment_entry(...)
```
@ -663,7 +663,7 @@ async def create_payment_entry(
1. Most receivables created in EUR
2. Financial reporting requirements typically in fiat
3. Tax obligations calculated in fiat
4. Aligns with current Castle metadata approach
4. Aligns with current Libra metadata approach
---
@ -681,7 +681,7 @@ async def create_payment_entry(
**Cryptocurrency Sub-Ledger** (SATS-denominated):
```beancount
2025-11-12 * "Lightning payment received"
Assets:Bitcoin:Lightning:Castle 225033 SATS
Assets:Bitcoin:Lightning:Libra 225033 SATS
Assets:Bitcoin:Custody:User-375ec 225033 SATS
```
@ -821,7 +821,7 @@ async def create_payment_entry(
**Is this "best practice" accounting?**
**No**, this implementation deviates from traditional accounting standards in several ways.
**Is it acceptable for Castle's use case?**
**Is it acceptable for Libra's use case?**
**Yes, with modifications**, it's a reasonable pragmatic solution for a novel problem (cryptocurrency payments of fiat debts).
**Critical improvements needed**:
@ -829,7 +829,7 @@ async def create_payment_entry(
2. ✅ Implement exchange gain/loss tracking (required for compliance)
3. ✅ Separate payment vs. settlement logic (accuracy and clarity)
**The fundamental challenge**: Traditional accounting wasn't designed for this scenario. There is no established "standard" for recording cryptocurrency payments of fiat-denominated receivables. Castle's approach is functional, but should be refined to align better with accounting principles where possible.
**The fundamental challenge**: Traditional accounting wasn't designed for this scenario. There is no established "standard" for recording cryptocurrency payments of fiat-denominated receivables. Libra's approach is functional, but should be refined to align better with accounting principles where possible.
### Next Steps
@ -847,7 +847,7 @@ async def create_payment_entry(
- **FASB Concept Statement No. 2**: Qualitative Characteristics of Accounting Information
- **ASC 105-10-05**: Substance Over Form
- **Beancount Documentation**: http://furius.ca/beancount/doc/index
- **Castle Extension**: `docs/SATS-EQUIVALENT-METADATA.md`
- **Libra Extension**: `docs/SATS-EQUIVALENT-METADATA.md`
- **BQL Analysis**: `docs/BQL-BALANCE-QUERIES.md`
---
@ -858,4 +858,4 @@ async def create_payment_entry(
---
*This analysis was prepared for internal review and development planning. It represents a professional accounting assessment of the current implementation and should be used to guide improvements to Castle's payment recording system.*
*This analysis was prepared for internal review and development planning. It represents a professional accounting assessment of the current implementation and should be used to guide improvements to Libra's payment recording system.*

View file

@ -1,8 +1,8 @@
# Beancount Patterns Analysis for Castle Extension
# Beancount Patterns Analysis for Libra Extension
## Overview
After analyzing the [Beancount repository](https://github.com/beancount/beancount), I've identified several excellent design patterns and architectural decisions that we should adopt or consider for the Castle Accounting extension.
After analyzing the [Beancount repository](https://github.com/beancount/beancount), I've identified several excellent design patterns and architectural decisions that we should adopt or consider for the Libra extension.
## Key Patterns to Adopt
@ -38,7 +38,7 @@ class Posting(NamedTuple):
- More memory efficient than regular classes
- Thread-safe by design
**Castle Application:**
**Libra Application:**
```python
# In models.py
from typing import NamedTuple, Optional
@ -109,15 +109,15 @@ def validate_commodity_directives(entries, options_map, config):
return entries, errors
```
**Castle Application:**
**Libra Application:**
```python
# Create plugins/ directory
# lnbits/extensions/castle/plugins/__init__.py
# lnbits/extensions/libra/plugins/__init__.py
from typing import Protocol, Tuple, List, Any
class CastlePlugin(Protocol):
"""Protocol for Castle plugins"""
class LibraPlugin(Protocol):
"""Protocol for Libra plugins"""
def __call__(
self,
@ -130,7 +130,7 @@ class CastlePlugin(Protocol):
Args:
entries: Journal entries to process
settings: Castle settings
settings: Libra settings
config: Plugin-specific configuration
Returns:
@ -212,7 +212,7 @@ class PluginManager:
if plugin_file.name.startswith('_'):
continue
module_name = f"castle.plugins.{plugin_file.stem}"
module_name = f"libra.plugins.{plugin_file.stem}"
module = importlib.import_module(module_name)
if hasattr(module, '__plugins__'):
@ -276,7 +276,7 @@ class Inventory(dict[tuple[str, Optional[Cost]], Position]):
)
```
**Castle Application:**
**Libra Application:**
```python
# core/inventory.py
from decimal import Decimal
@ -284,8 +284,8 @@ from typing import Optional, Dict, Tuple
from dataclasses import dataclass
@dataclass(frozen=True)
class CastlePosition:
"""A position in the Castle inventory"""
class LibraPosition:
"""A position in the Libra inventory"""
currency: str # "SATS", "EUR", "USD"
amount: Decimal
cost_currency: Optional[str] = None # Original currency if converted
@ -293,22 +293,22 @@ class CastlePosition:
date: Optional[datetime] = None
metadata: Dict[str, Any] = None
class CastleInventory:
class LibraInventory:
"""
Track user balances across multiple currencies with conversion tracking.
Similar to Beancount's Inventory but optimized for Castle's use case.
Similar to Beancount's Inventory but optimized for Libra's use case.
"""
def __init__(self):
self.positions: Dict[Tuple[str, Optional[str]], CastlePosition] = {}
self.positions: Dict[Tuple[str, Optional[str]], LibraPosition] = {}
def add_position(self, position: CastlePosition):
def add_position(self, position: LibraPosition):
"""Add or merge a position"""
key = (position.currency, position.cost_currency)
if key in self.positions:
existing = self.positions[key]
self.positions[key] = CastlePosition(
self.positions[key] = LibraPosition(
currency=position.currency,
amount=existing.amount + position.amount,
cost_currency=position.cost_currency,
@ -353,9 +353,9 @@ class CastleInventory:
}
# Usage in balance calculation:
async def get_user_inventory(user_id: str) -> CastleInventory:
async def get_user_inventory(user_id: str) -> LibraInventory:
"""Calculate user's inventory from journal entries"""
inventory = CastleInventory()
inventory = LibraInventory()
user_accounts = await get_user_accounts(user_id)
for account in user_accounts:
@ -369,7 +369,7 @@ async def get_user_inventory(user_id: str) -> CastleInventory:
# Beancount-style: positive = debit, negative = credit
# Adjust sign for cost amount based on amount direction
cost_sign = 1 if line.amount > 0 else -1
inventory.add_position(CastlePosition(
inventory.add_position(LibraPosition(
currency="SATS",
amount=Decimal(line.amount),
cost_currency=metadata.get("fiat_currency"),
@ -397,7 +397,7 @@ Every directive has a `meta: dict[str, Any]` attribute that stores:
- `lineno`: Line number
- Custom metadata like tags, links, notes
**Castle Application:**
**Libra Application:**
```python
class JournalEntryMeta(BaseModel):
"""Metadata for journal entries"""
@ -447,7 +447,7 @@ entry = await create_journal_entry(
This asserts that the account balance should be exactly 268,548 sats on that date. If it's not, Beancount throws an error.
**Castle Application:**
**Libra Application:**
```python
# models.py
class BalanceAssertion(BaseModel):
@ -464,7 +464,7 @@ class BalanceAssertion(BaseModel):
created_at: datetime
# API endpoint
@castle_api_router.post("/api/v1/assertions/balance")
@libra_api_router.post("/api/v1/assertions/balance")
async def create_balance_assertion(
data: CreateBalanceAssertion,
wallet: WalletTypeInfo = Depends(require_admin_key),
@ -554,7 +554,7 @@ Liabilities:US:CreditCard:Amex
Accounts are organized hierarchically with `:` separator.
**Castle Application:**
**Libra Application:**
```python
# Currently: "Accounts Receivable - af983632"
# Better: "Assets:Receivable:User-af983632"
@ -617,7 +617,7 @@ def format_account_name(
Flags: `*` = cleared, `!` = pending, `#` = flagged for review
**Castle Application:**
**Libra Application:**
```python
# Add flag field to journal_entries
class JournalEntryFlag(str, Enum):
@ -661,7 +661,7 @@ from decimal import Decimal
amount = Decimal("19.99")
```
**Castle Current Issue:**
**Libra Current Issue:**
We're using `int` for satoshis (good!) but `float` for fiat amounts (bad!).
**Fix:**
@ -709,10 +709,10 @@ WHERE account = 'Assets:Checking'
AND date >= 2025-01-01;
```
**Castle Application (Future):**
**Libra Application (Future):**
```python
# Add query endpoint
@castle_api_router.post("/api/v1/query")
@libra_api_router.post("/api/v1/query")
async def execute_query(
query: str,
wallet: WalletTypeInfo = Depends(require_invoice_key),
@ -756,12 +756,12 @@ beancount/
tools/ # Reporting and analysis
```
**Castle Should Adopt:**
**Libra Should Adopt:**
```
castle/
libra/
core/ # NEW: Pure accounting logic
__init__.py
inventory.py # CastleInventory for position tracking
inventory.py # LibraInventory for position tracking
balance.py # Balance calculation logic
validation.py # Entry validation (debits=credits, etc)
account.py # Account hierarchy and naming
@ -805,11 +805,11 @@ def validate_entries(entries):
return errors
```
**Castle Application:**
**Libra Application:**
```python
from typing import NamedTuple, Optional
class CastleError(NamedTuple):
class LibraError(NamedTuple):
"""Base error type"""
source: dict # {'endpoint': '...', 'user_id': '...'}
message: str
@ -828,7 +828,7 @@ class UnbalancedEntryError(NamedTuple):
difference: int
# Return errors from validation
async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]:
async def validate_journal_entry(entry: CreateJournalEntry) -> list[LibraError]:
errors = []
# Beancount-style: sum of amounts must equal 0
@ -870,7 +870,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]
### Phase 3: Core Logic Refactoring (Medium Priority) ✅ COMPLETE
9. ✅ Create `core/` module with pure accounting logic
10. ✅ Implement `CastleInventory` for position tracking
10. ✅ Implement `LibraInventory` for position tracking
11. ✅ Move balance calculation to `core/balance.py`
12. ✅ Add comprehensive validation in `core/validation.py`
@ -900,7 +900,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]
7. ✅ Separation of core logic from I/O
8. ✅ Comprehensive validation
**What Castle Should Adopt First:**
**What Libra Should Adopt First:**
1. **Decimal for fiat amounts** (prevent rounding errors)
2. **Meta field** (audit trail, source tracking)
3. **Flag field** (transaction status)
@ -916,7 +916,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]
## Conclusion
Beancount's architecture is battle-tested for serious accounting work. By adopting these patterns, Castle can:
Beancount's architecture is battle-tested for serious accounting work. By adopting these patterns, Libra can:
- Prevent financial calculation errors (Decimal)
- Support complex workflows (plugins)
- Build user trust (balance assertions, audit trail)

View file

@ -496,7 +496,7 @@ Improvement: 5-10x faster
## Test Results and Findings
**Date**: November 10, 2025
**Status**: ⚠️ **NOT FEASIBLE for Castle's Current Data Structure**
**Status**: ⚠️ **NOT FEASIBLE for Libra's Current Data Structure**
### Implementation Completed
@ -523,7 +523,7 @@ Improvement: 5-10x faster
### Root Cause: Architecture Limitation
**Current Castle Ledger Structure:**
**Current Libra Ledger Structure:**
```
Posting format:
Amount: -360.00 EUR ← Position (BQL can query this)
@ -549,7 +549,7 @@ SELECT account, sum(meta["sats-equivalent"]) WHERE account ~ 'User-'
### Why Manual Aggregation is Necessary
1. **SATS are Castle's primary currency** for balance tracking
1. **SATS are Libra's primary currency** for balance tracking
2. **SATS values are in metadata**, not positions
3. **BQL has no metadata query capability**
4. **Must iterate through postings** to read `meta["sats-equivalent"]`
@ -590,7 +590,7 @@ SELECT account, sum(meta["sats-equivalent"]) WHERE account ~ 'User-'
## Future Consideration: Ledger Format Change
**If** Castle's ledger format changes to use SATS as position amounts:
**If** Libra's ledger format changes to use SATS as position amounts:
```beancount
; Current format (EUR position, SATS in metadata):

View file

@ -63,7 +63,7 @@ Expenses:Food -360.00 EUR @@ 337096 SATS
**Total calculation**: Exact 337,096 SATS (no rounding)
**Precision**: Preserves exact SATS amount from original calculation
**Why `@@` is better for Castle:**
**Why `@@` is better for Libra:**
- ✅ Preserves exact SATS amount (no rounding errors)
- ✅ Matches current metadata storage exactly
- ✅ Clearer intent: "this transaction equals X SATS total"
@ -124,7 +124,7 @@ GROUP BY account;
### Step 1: Run Metadata Test
```bash
cd /home/padreug/projects/castle-beancounter
cd /home/padreug/projects/libra-beancounter
./test_metadata_simple.sh
```
@ -166,7 +166,7 @@ Add one test entry to your ledger:
Then query:
```bash
curl -s "http://localhost:3333/castle-ledger/api/query" \
curl -s "http://localhost:3333/libra-ledger/api/query" \
-G \
--data-urlencode "query_string=SELECT account, position, price_number, price_currency WHERE account ~ 'TEST'" \
| jq '.'

View file

@ -1,6 +1,6 @@
# Automated Daily Reconciliation
The Castle extension includes automated daily balance checking to ensure accounting accuracy.
The Libra extension includes automated daily balance checking to ensure accounting accuracy.
## Overview
@ -16,7 +16,7 @@ You can manually trigger the reconciliation check from the UI or via API:
### Via API
```bash
curl -X POST https://your-lnbits-instance.com/castle/api/v1/tasks/daily-reconciliation \
curl -X POST https://your-lnbits-instance.com/libra/api/v1/tasks/daily-reconciliation \
-H "X-Api-Key: YOUR_ADMIN_KEY"
```
@ -28,7 +28,7 @@ Add to your crontab:
```bash
# Run daily at 2 AM
0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" >> /var/log/castle-reconciliation.log 2>&1
0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" >> /var/log/libra-reconciliation.log 2>&1
```
To edit crontab:
@ -38,22 +38,22 @@ crontab -e
### Option 2: Systemd Timer
Create `/etc/systemd/system/castle-reconciliation.service`:
Create `/etc/systemd/system/libra-reconciliation.service`:
```ini
[Unit]
Description=Castle Daily Reconciliation Check
Description=Libra Daily Reconciliation Check
After=network.target
[Service]
Type=oneshot
User=lnbits
ExecStart=/usr/bin/curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
ExecStart=/usr/bin/curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
```
Create `/etc/systemd/system/castle-reconciliation.timer`:
Create `/etc/systemd/system/libra-reconciliation.timer`:
```ini
[Unit]
Description=Run Castle reconciliation daily
Description=Run Libra reconciliation daily
[Timer]
OnCalendar=daily
@ -66,8 +66,8 @@ WantedBy=timers.target
Enable and start:
```bash
sudo systemctl enable castle-reconciliation.timer
sudo systemctl start castle-reconciliation.timer
sudo systemctl enable libra-reconciliation.timer
sudo systemctl start libra-reconciliation.timer
```
### Option 3: Docker/Kubernetes CronJob
@ -78,7 +78,7 @@ For containerized deployments:
apiVersion: batch/v1
kind: CronJob
metadata:
name: castle-reconciliation
name: libra-reconciliation
spec:
schedule: "0 2 * * *" # Daily at 2 AM
jobTemplate:
@ -91,7 +91,7 @@ spec:
args:
- /bin/sh
- -c
- curl -X POST http://lnbits:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ${ADMIN_KEY}"
- curl -X POST http://lnbits:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ${ADMIN_KEY}"
restartPolicy: OnFailure
```
@ -129,7 +129,7 @@ The endpoint returns:
grep CRON /var/log/syslog
# View custom log (if using cron with redirect)
tail -f /var/log/castle-reconciliation.log
tail -f /var/log/libra-reconciliation.log
```
### Success Criteria
@ -142,7 +142,7 @@ tail -f /var/log/castle-reconciliation.log
If `failed > 0`:
1. Check the `failed_assertions` array for details
2. Investigate discrepancies in the Castle UI
2. Investigate discrepancies in the Libra UI
3. Review recent transactions
4. Check for data entry errors
5. Verify exchange rate conversions (for fiat)
@ -172,7 +172,7 @@ Planned features:
3. **Check network connectivity**:
```bash
curl http://localhost:5000/castle/api/v1/reconciliation/summary -H "X-Api-Key: YOUR_KEY"
curl http://localhost:5000/libra/api/v1/reconciliation/summary -H "X-Api-Key: YOUR_KEY"
```
### Permission Denied
@ -202,31 +202,31 @@ Planned features:
```bash
#!/bin/bash
# setup-castle-reconciliation.sh
# setup-libra-reconciliation.sh
# Configuration
LNBITS_URL="http://localhost:5000"
ADMIN_KEY="your_admin_key_here"
LOG_FILE="/var/log/castle-reconciliation.log"
LOG_FILE="/var/log/libra-reconciliation.log"
# Create log file
touch "$LOG_FILE"
chmod 644 "$LOG_FILE"
# Add cron job
(crontab -l 2>/dev/null; echo "0 2 * * * curl -X POST $LNBITS_URL/castle/api/v1/tasks/daily-reconciliation -H 'X-Api-Key: $ADMIN_KEY' >> $LOG_FILE 2>&1") | crontab -
(crontab -l 2>/dev/null; echo "0 2 * * * curl -X POST $LNBITS_URL/libra/api/v1/tasks/daily-reconciliation -H 'X-Api-Key: $ADMIN_KEY' >> $LOG_FILE 2>&1") | crontab -
echo "Daily reconciliation scheduled for 2 AM"
echo "Logs will be written to: $LOG_FILE"
# Test the endpoint
echo "Running test reconciliation..."
curl -X POST "$LNBITS_URL/castle/api/v1/tasks/daily-reconciliation" \
curl -X POST "$LNBITS_URL/libra/api/v1/tasks/daily-reconciliation" \
-H "X-Api-Key: $ADMIN_KEY"
```
Make executable and run:
```bash
chmod +x setup-castle-reconciliation.sh
./setup-castle-reconciliation.sh
chmod +x setup-libra-reconciliation.sh
./setup-libra-reconciliation.sh
```

View file

@ -1,8 +1,8 @@
# Castle Accounting Extension - Comprehensive Documentation
# Libra Extension - Comprehensive Documentation
## Overview
The Castle Accounting extension for LNbits implements a double-entry bookkeeping system designed for cooperative/communal living spaces (like "castles"). It tracks financial relationships between a central entity (the Castle) and multiple users, handling both Lightning Network payments and manual/cash transactions.
The Libra extension for LNbits implements a double-entry bookkeeping system designed for collectives like co-living spaces, makerspaces, and community projects. It tracks financial relationships between a central organization and its members, handling both Lightning Network payments and manual/cash transactions.
## Architecture
@ -19,23 +19,23 @@ The system implements traditional **double-entry bookkeeping** principles:
| Account Type | Normal Balance | Increases With | Decreases With | Purpose |
|--------------|----------------|----------------|----------------|---------|
| Asset | Debit | Debit | Credit | What Castle owns or is owed |
| Liability | Credit | Credit | Debit | What Castle owes to others |
| Asset | Debit | Debit | Credit | What Libra owns or is owed |
| Liability | Credit | Credit | Debit | What Libra owes to others |
| Equity | Credit | Credit | Debit | Member contributions, retained earnings |
| Revenue | Credit | Credit | Debit | Income earned by Castle |
| Expense | Debit | Debit | Credit | Costs incurred by Castle |
| Revenue | Credit | Credit | Debit | Income earned by Libra |
| Expense | Debit | Debit | Credit | Costs incurred by Libra |
### User-Specific Accounts
The system creates **per-user accounts** for tracking individual balances:
- `Accounts Receivable - {user_id[:8]}` (Asset) - User owes Castle
- `Accounts Payable - {user_id[:8]}` (Liability) - Castle owes User
- `Accounts Receivable - {user_id[:8]}` (Asset) - User owes Libra
- `Accounts Payable - {user_id[:8]}` (Liability) - Libra owes User
- `Member Equity - {user_id[:8]}` (Equity) - User's equity contributions
**Balance Interpretation:**
- `balance > 0` and account is Liability → Castle owes user (user is creditor)
- `balance < 0` (or positive Asset balance) → User owes Castle (user is debtor)
- `balance > 0` and account is Liability → Libra owes user (user is creditor)
- `balance < 0` (or positive Asset balance) → User owes Libra (user is debtor)
### Database Schema
@ -81,7 +81,7 @@ CREATE TABLE entry_lines (
```sql
CREATE TABLE extension_settings (
id TEXT NOT NULL PRIMARY KEY, -- Always "admin"
castle_wallet_id TEXT, -- LNbits wallet ID for Castle operations
libra_wallet_id TEXT, -- LNbits wallet ID for Libra operations
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
@ -129,11 +129,11 @@ Each `entry_line` can store metadata as JSON to preserve original fiat amounts:
### 1. User Adds Expense (Liability Model)
**Use Case:** User pays for groceries with cash, Castle reimburses them
**Use Case:** User pays for groceries with cash, Libra reimburses them
**User Action:** Add expense via UI
```javascript
POST /castle/api/v1/entries/expense
POST /libra/api/v1/entries/expense
{
"description": "Biocoop groceries",
"amount": 36.93,
@ -162,15 +162,15 @@ Metadata on both lines:
}
```
**Effect:** Castle owes user €36.93 (39,669 sats)
**Effect:** Libra owes user €36.93 (39,669 sats)
### 2. Castle Adds Receivable
### 2. Libra Adds Receivable
**Use Case:** User stays in a room, owes Castle for accommodation
**Use Case:** User stays in a room, owes Libra for accommodation
**Castle Admin Action:** Add receivable via UI
**Libra Admin Action:** Add receivable via UI
```javascript
POST /castle/api/v1/entries/receivable
POST /libra/api/v1/entries/receivable
{
"description": "room 5 days",
"amount": 250.0,
@ -198,7 +198,7 @@ Metadata:
}
```
**Effect:** User owes Castle €250.00 (268,548 sats)
**Effect:** User owes Libra €250.00 (268,548 sats)
### 3. User Pays with Lightning
@ -206,7 +206,7 @@ Metadata:
**Step A: Generate Invoice**
```javascript
POST /castle/api/v1/generate-payment-invoice
POST /libra/api/v1/generate-payment-invoice
{
"amount": 268548
}
@ -218,19 +218,19 @@ Returns:
"payment_hash": "...",
"payment_request": "lnbc...",
"amount": 268548,
"memo": "Payment from user af983632 to Castle",
"check_wallet_key": "castle_wallet_inkey"
"memo": "Payment from user af983632 to Libra",
"check_wallet_key": "libra_wallet_inkey"
}
```
**Note:** Invoice is generated on **Castle's wallet**, not user's wallet. User polls using `check_wallet_key`.
**Note:** Invoice is generated on **Libra's wallet**, not user's wallet. User polls using `check_wallet_key`.
**Step B: User Pays Invoice**
(External Lightning wallet or LNbits wallet)
**Step C: Record Payment**
```javascript
POST /castle/api/v1/record-payment
POST /libra/api/v1/record-payment
{
"payment_hash": "..."
}
@ -250,11 +250,11 @@ DR Lightning Balance 268,548 sats
### 4. Manual Payment Request Flow
**Use Case:** User wants Castle to pay them in cash instead of Lightning
**Use Case:** User wants Libra to pay them in cash instead of Lightning
**Step A: User Requests Payment**
```javascript
POST /castle/api/v1/manual-payment-requests
POST /libra/api/v1/manual-payment-requests
{
"amount": 39669,
"description": "Please pay me in cash for groceries"
@ -263,16 +263,16 @@ POST /castle/api/v1/manual-payment-requests
Creates `manual_payment_request` with status='pending'
**Step B: Castle Admin Reviews**
**Step B: Libra Admin Reviews**
Admin sees pending request in UI:
- User: af983632
- Amount: 39,669 sats (€36.93)
- Description: "Please pay me in cash for groceries"
**Step C: Castle Admin Approves**
**Step C: Libra Admin Approves**
```javascript
POST /castle/api/v1/manual-payment-requests/{id}/approve
POST /libra/api/v1/manual-payment-requests/{id}/approve
```
**Journal Entry Created:**
@ -285,11 +285,11 @@ DR Accounts Payable - af983632 39,669 sats
CR Lightning Balance 39,669 sats
```
**Effect:** Castle's liability to user reduced by 39,669 sats
**Effect:** Libra's liability to user reduced by 39,669 sats
**Alternative: Castle Admin Rejects**
**Alternative: Libra Admin Rejects**
```javascript
POST /castle/api/v1/manual-payment-requests/{id}/reject
POST /libra/api/v1/manual-payment-requests/{id}/reject
```
No journal entry created, request marked as 'rejected'.
@ -308,20 +308,20 @@ for account in user_accounts:
# Calculate satoshi balance
if account.account_type == AccountType.LIABILITY:
total_balance += account_balance # Positive = Castle owes user
total_balance += account_balance # Positive = Libra owes user
elif account.account_type == AccountType.ASSET:
total_balance -= account_balance # Positive asset = User owes Castle, so negative balance
total_balance -= account_balance # Positive asset = User owes Libra, so negative balance
# Calculate fiat balance from metadata
# Beancount-style: positive amount = debit, negative amount = credit
for line in account_entry_lines:
if line.metadata.fiat_currency and line.metadata.fiat_amount:
if account.account_type == AccountType.LIABILITY:
# For liabilities, negative amounts (credits) increase what castle owes
# For liabilities, negative amounts (credits) increase what libra owes
if line.amount < 0:
fiat_balances[currency] += fiat_amount # Castle owes more
fiat_balances[currency] += fiat_amount # Libra owes more
else:
fiat_balances[currency] -= fiat_amount # Castle owes less
fiat_balances[currency] -= fiat_amount # Libra owes less
elif account.account_type == AccountType.ASSET:
# For assets, positive amounts (debits) increase what user owes
if line.amount > 0:
@ -331,19 +331,19 @@ for account in user_accounts:
```
**Result:**
- `balance > 0`: Castle owes user (LIABILITY side dominates)
- `balance < 0`: User owes Castle (ASSET side dominates)
- `balance > 0`: Libra owes user (LIABILITY side dominates)
- `balance < 0`: User owes Libra (ASSET side dominates)
- `fiat_balances`: Net fiat position per currency
### Castle Balance Calculation
### Libra Balance Calculation
From `views_api.py:api_get_my_balance()` (super user):
```python
all_balances = get_all_user_balances()
total_liabilities = sum(b.balance for b in all_balances if b.balance > 0) # What Castle owes
total_receivables = sum(abs(b.balance) for b in all_balances if b.balance < 0) # What is owed to Castle
total_liabilities = sum(b.balance for b in all_balances if b.balance > 0) # What Libra owes
total_receivables = sum(abs(b.balance) for b in all_balances if b.balance < 0) # What is owed to Libra
net_balance = total_liabilities - total_receivables
# Aggregate all fiat balances
@ -354,34 +354,34 @@ for user_balance in all_balances:
```
**Result:**
- `net_balance > 0`: Castle owes users (net liability)
- `net_balance < 0`: Users owe Castle (net receivable)
- `net_balance > 0`: Libra owes users (net liability)
- `net_balance < 0`: Users owe Libra (net receivable)
## UI/UX Design
### Perspective-Based Display
The UI adapts based on whether the viewer is a regular user or Castle admin (super user):
The UI adapts based on whether the viewer is a regular user or Libra admin (super user):
#### User View
**Balance Display:**
- Green text: Castle owes them (positive balance, incoming money)
- Red text: They owe Castle (negative balance, outgoing money)
- Green text: Libra owes them (positive balance, incoming money)
- Red text: They owe Libra (negative balance, outgoing money)
**Transaction Badges:**
- Green "Receivable": Castle owes them (Accounts Payable entry)
- Red "Payable": They owe Castle (Accounts Receivable entry)
- Green "Receivable": Libra owes them (Accounts Payable entry)
- Red "Payable": They owe Libra (Accounts Receivable entry)
#### Castle Admin View (Super User)
#### Libra Admin View (Super User)
**Balance Display:**
- Red text: Castle owes users (positive balance, outgoing money)
- Green text: Users owe Castle (negative balance, incoming money)
- Red text: Libra owes users (positive balance, outgoing money)
- Green text: Users owe Libra (negative balance, incoming money)
**Transaction Badges:**
- Green "Receivable": User owes Castle (Accounts Receivable entry)
- Red "Payable": Castle owes user (Accounts Payable entry)
- Green "Receivable": User owes Libra (Accounts Receivable entry)
- Red "Payable": Libra owes user (Accounts Payable entry)
**Outstanding Balances Table:**
Shows all users with non-zero balances:
@ -411,10 +411,10 @@ Created by `m001_initial` migration:
- `cash` - Cash on hand
- `bank` - Bank Account
- `lightning` - Lightning Balance
- `accounts_receivable` - Money owed to the Castle
- `accounts_receivable` - Money owed to the organization
### Liabilities
- `accounts_payable` - Money owed by the Castle
- `accounts_payable` - Money owed by the organization
### Equity
- `member_equity` - Member contributions
@ -449,11 +449,11 @@ Created by `m001_initial` migration:
- `POST /api/v1/entries/revenue` - Create direct revenue entry (admin only)
### Balance & Payments
- `GET /api/v1/balance` - Get current user's balance (or Castle total if super user)
- `GET /api/v1/balance` - Get current user's balance (or Libra total if super user)
- `GET /api/v1/balance/{user_id}` - Get specific user's balance
- `GET /api/v1/balances/all` - Get all user balances (admin only, enriched with usernames)
- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Castle
- `POST /api/v1/record-payment` - Record Lightning payment to Castle
- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Libra
- `POST /api/v1/record-payment` - Record Lightning payment to Libra
### Manual Payments
- `POST /api/v1/manual-payment-requests` - User creates manual payment request
@ -463,8 +463,8 @@ Created by `m001_initial` migration:
- `POST /api/v1/manual-payment-requests/{id}/reject` - Admin rejects request
### Settings
- `GET /api/v1/settings` - Get Castle settings (super user only)
- `PUT /api/v1/settings` - Update Castle settings (super user only)
- `GET /api/v1/settings` - Get Libra settings (super user only)
- `PUT /api/v1/settings` - Update Libra settings (super user only)
- `GET /api/v1/user/wallet` - Get user's wallet settings
- `PUT /api/v1/user/wallet` - Update user's wallet settings
- `GET /api/v1/users` - Get all users with configured wallets (admin only)
@ -712,7 +712,7 @@ GET /api/v1/entries/user?start_date=2025-01-01&end_date=2025-03-31
**Add Endpoint:**
```python
@castle_api_router.get("/api/v1/export/beancount")
@libra_api_router.get("/api/v1/export/beancount")
async def export_beancount(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
@ -812,7 +812,7 @@ async def export_beancount(
**UI Addition:**
Add export button to Castle admin UI:
Add export button to Libra admin UI:
```html
<q-btn color="primary" @click="exportBeancount">
Export to Beancount
@ -825,7 +825,7 @@ async exportBeancount() {
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/export/beancount',
'/libra/api/v1/export/beancount',
this.g.user.wallets[0].adminkey
)
@ -834,7 +834,7 @@ async exportBeancount() {
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `castle-accounting-${new Date().toISOString().split('T')[0]}.beancount`
link.download = `libra-accounting-${new Date().toISOString().split('T')[0]}.beancount`
link.click()
window.URL.revokeObjectURL(url)
@ -854,12 +854,12 @@ After export, users can verify with Beancount:
```bash
# Check file is valid
bean-check castle-accounting-2025-10-22.beancount
bean-check libra-accounting-2025-10-22.beancount
# Generate reports
bean-report castle-accounting-2025-10-22.beancount balances
bean-report castle-accounting-2025-10-22.beancount income
bean-web castle-accounting-2025-10-22.beancount
bean-report libra-accounting-2025-10-22.beancount balances
bean-report libra-accounting-2025-10-22.beancount income
bean-web libra-accounting-2025-10-22.beancount
```
## Testing Strategy
@ -891,7 +891,7 @@ bean-web castle-accounting-2025-10-22.beancount
1. **End-to-End User Flow**
- User adds expense
- Castle adds receivable
- Libra adds receivable
- User pays via Lightning
- Verify balances at each step
@ -904,7 +904,7 @@ bean-web castle-accounting-2025-10-22.beancount
3. **Multi-User Scenarios**
- Multiple users with positive balances
- Multiple users with negative balances
- Verify Castle net balance calculation
- Verify Libra net balance calculation
## Security Considerations
@ -916,12 +916,12 @@ bean-web castle-accounting-2025-10-22.beancount
2. **User Isolation**
- Users can only see their own balances and transactions
- Users cannot create receivables (only Castle admin can)
- Users cannot create receivables (only Libra admin can)
- Users cannot approve their own manual payment requests
3. **Wallet Key Requirements**
- `require_invoice_key`: Read access to user's data
- `require_admin_key`: Write access, Castle admin operations
- `require_admin_key`: Write access, Libra admin operations
### Potential Vulnerabilities
@ -959,7 +959,7 @@ bean-web castle-accounting-2025-10-22.beancount
limiter = Limiter(key_func=get_remote_address)
@limiter.limit("10/minute")
@castle_api_router.post("/api/v1/entries/expense")
@libra_api_router.post("/api/v1/entries/expense")
async def api_create_expense_entry(...):
...
```
@ -1020,7 +1020,7 @@ bean-web castle-accounting-2025-10-22.beancount
2. **Add Pagination**
```python
@castle_api_router.get("/api/v1/entries/user")
@libra_api_router.get("/api/v1/entries/user")
async def api_get_user_entries(
wallet: WalletTypeInfo = Depends(require_invoice_key),
limit: int = 100,
@ -1092,7 +1092,7 @@ bean-web castle-accounting-2025-10-22.beancount
## Migration Path for Existing Data
If Castle is already in production with the old code:
If Libra is already in production with the old code:
### Migration Script: `m005_fix_user_accounts.py`
@ -1185,7 +1185,7 @@ async def m005_fix_user_accounts(db):
## Conclusion
The Castle Accounting extension provides a solid foundation for double-entry bookkeeping in LNbits. The core accounting logic is sound, with proper debit/credit handling and user-specific account isolation.
The Libra extension provides a solid foundation for double-entry bookkeeping in LNbits. The core accounting logic is sound, with proper debit/credit handling and user-specific account isolation.
### Strengths
✅ Correct double-entry bookkeeping implementation
@ -1193,7 +1193,7 @@ The Castle Accounting extension provides a solid foundation for double-entry boo
✅ Metadata preservation for fiat amounts
✅ Lightning payment integration
✅ Manual payment workflow
✅ Perspective-based UI (user vs Castle view)
✅ Perspective-based UI (user vs Libra view)
### Immediate Action Items
1. ✅ Fix user account creation bug (COMPLETED)
@ -1210,4 +1210,4 @@ The Castle Accounting extension provides a solid foundation for double-entry boo
5. Equity management features
6. External system integrations (accounting software, tax tools)
The refactoring path is clear: prioritize data integrity, then add reporting/export, then enhance with advanced features. The system is production-ready for basic use cases but needs the recommended enhancements for a full-featured cooperative accounting solution.
The refactoring path is clear: prioritize data integrity, then add reporting/export, then enhance with advanced features. The system is production-ready for basic use cases but needs the recommended enhancements for a full-featured collective accounting solution.

View file

@ -2,7 +2,7 @@
## Overview
The Castle extension now requires admin approval for all user-submitted expenses. This prevents invalid or incorrect expenses from affecting balances until they are verified by the Castle admin.
The Libra extension now requires admin approval for all user-submitted expenses. This prevents invalid or incorrect expenses from affecting balances until they are verified by an admin.
## How It Works
@ -61,7 +61,7 @@ AND je.flag = '*' -- Only cleared entries
### Get Pending Entries (Admin Only)
```
GET /castle/api/v1/entries/pending
GET /libra/api/v1/entries/pending
Authorization: Admin Key
Returns: list[JournalEntry]
@ -69,7 +69,7 @@ Returns: list[JournalEntry]
### Approve Expense (Admin Only)
```
POST /castle/api/v1/entries/{entry_id}/approve
POST /libra/api/v1/entries/{entry_id}/approve
Authorization: Admin Key
Returns: JournalEntry (with flag='*')
@ -77,7 +77,7 @@ Returns: JournalEntry (with flag='*')
### Reject Expense (Admin Only)
```
POST /castle/api/v1/entries/{entry_id}/reject
POST /libra/api/v1/entries/{entry_id}/reject
Authorization: Admin Key
Returns: JournalEntry (with flag='x')
@ -133,7 +133,7 @@ Returns: JournalEntry (with flag='x')
1. **Submit test expense as regular user**
```
POST /castle/api/v1/entries/expense
POST /libra/api/v1/entries/expense
{
"description": "Test groceries",
"amount": 50.00,

View file

@ -1,4 +1,4 @@
# Castle Permissions System - Overview & Administration Guide
# Libra Permissions System - Overview & Administration Guide
**Date**: November 10, 2025
**Status**: 📚 **Documentation** + 🔧 **Improvement Recommendations**
@ -7,7 +7,7 @@
## Executive Summary
Castle implements a **granular, hierarchical permission system** that controls who can access which accounts and perform what actions. The system supports permission inheritance, making it easy to grant access to entire account hierarchies with a single permission.
Libra implements a **granular, hierarchical permission system** that controls who can access which accounts and perform what actions. The system supports permission inheritance, making it easy to grant access to entire account hierarchies with a single permission.
**Key Features:**
- ✅ **Three permission levels**: READ, SUBMIT_EXPENSE, MANAGE
@ -680,7 +680,7 @@ CREATE TABLE account_permissions (
expires_at TIMESTAMP,
notes TEXT,
FOREIGN KEY (account_id) REFERENCES castle_accounts (id)
FOREIGN KEY (account_id) REFERENCES libra_accounts (id)
);
CREATE INDEX idx_account_permissions_user_id ON account_permissions (user_id);
@ -840,7 +840,7 @@ async def test_expense_submission_without_permission():
## Summary
The Castle permissions system is **well-designed** with strong features:
The Libra permissions system is **well-designed** with strong features:
- Hierarchical inheritance reduces admin burden
- Caching provides good performance
- Expiration and audit trail support compliance

View file

@ -36,7 +36,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom
- `POST /api/v1/assertions/{id}/check` - Re-check assertion
- `DELETE /api/v1/assertions/{id}` - Delete assertion
- **UI** (`templates/castle/index.html:254-378`):
- **UI** (`templates/libra/index.html:254-378`):
- Balance Assertions card (super user only)
- Failed assertions prominently displayed with red banner
- Passed assertions in collapsible panel
@ -77,7 +77,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom
**Purpose**: Visual dashboard for reconciliation status and quick access to reconciliation tools
**Implementation** (`templates/castle/index.html:380-499`):
**Implementation** (`templates/libra/index.html:380-499`):
- **Summary Cards**:
- Balance Assertions stats (total, passed, failed, pending)
- Journal Entries stats (total, cleared, pending, flagged)
@ -161,7 +161,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom
2. `migrations.py` - Added `m007_balance_assertions` migration
3. `crud.py` - Added balance assertion CRUD operations
4. `views_api.py` - Added assertion, reconciliation, and task endpoints
5. `templates/castle/index.html` - Added assertions and reconciliation UI
5. `templates/libra/index.html` - Added assertions and reconciliation UI
6. `static/js/index.js` - Added assertion and reconciliation functionality
7. `BEANCOUNT_PATTERNS.md` - Updated roadmap to mark Phase 2 complete
@ -186,7 +186,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom
### Create a Balance Assertion
```bash
curl -X POST http://localhost:5000/castle/api/v1/assertions \
curl -X POST http://localhost:5000/libra/api/v1/assertions \
-H "X-Api-Key: ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
@ -198,20 +198,20 @@ curl -X POST http://localhost:5000/castle/api/v1/assertions \
### Get Reconciliation Summary
```bash
curl http://localhost:5000/castle/api/v1/reconciliation/summary \
curl http://localhost:5000/libra/api/v1/reconciliation/summary \
-H "X-Api-Key: ADMIN_KEY"
```
### Run Full Reconciliation
```bash
curl -X POST http://localhost:5000/castle/api/v1/reconciliation/check-all \
curl -X POST http://localhost:5000/libra/api/v1/reconciliation/check-all \
-H "X-Api-Key: ADMIN_KEY"
```
### Schedule Daily Reconciliation (Cron)
```bash
# Add to crontab
0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ADMIN_KEY"
0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ADMIN_KEY"
```
## Testing Checklist
@ -238,7 +238,7 @@ curl -X POST http://localhost:5000/castle/api/v1/reconciliation/check-all \
**Phase 3: Core Logic Refactoring (Medium Priority)**
- Create `core/` module with pure accounting logic
- Implement `CastleInventory` for position tracking
- Implement `LibraInventory` for position tracking
- Move balance calculation to `core/balance.py`
- Add comprehensive validation in `core/validation.py`
@ -256,7 +256,7 @@ curl -X POST http://localhost:5000/castle/api/v1/reconciliation/check-all \
## Conclusion
Phase 2 successfully implements Beancount's reconciliation philosophy in the Castle extension. With balance assertions, comprehensive reconciliation APIs, a visual dashboard, and automated daily checks, users can:
Phase 2 successfully implements Beancount's reconciliation philosophy in the Libra extension. With balance assertions, comprehensive reconciliation APIs, a visual dashboard, and automated daily checks, users can:
- **Trust their data** with automated verification
- **Catch errors early** through regular reconciliation

View file

@ -21,13 +21,13 @@ Phase 3 of the Beancount-inspired refactor focused on **separating business logi
- Easier to audit and verify
- Clear architecture
### 2. CastleInventory for Position Tracking ✅
### 2. LibraInventory for Position Tracking ✅
**Purpose**: Track balances across multiple currencies with cost basis information (following Beancount's Inventory pattern)
**Implementation** (`core/inventory.py`):
**CastlePosition** (Lines 11-84):
**LibraPosition** (Lines 11-84):
- Immutable dataclass representing a single position
- Tracks currency, amount, cost basis, and metadata
- Supports addition and negation operations
@ -35,7 +35,7 @@ Phase 3 of the Beancount-inspired refactor focused on **separating business logi
```python
@dataclass(frozen=True)
class CastlePosition:
class LibraPosition:
currency: str # "SATS", "EUR", "USD"
amount: Decimal
cost_currency: Optional[str] = None
@ -44,7 +44,7 @@ class CastlePosition:
metadata: Dict[str, Any] = field(default_factory=dict)
```
**CastleInventory** (Lines 87-201):
**LibraInventory** (Lines 87-201):
- Container for multiple positions
- Positions keyed by `(currency, cost_currency)` tuple
- Methods for querying balances:
@ -83,7 +83,7 @@ class AccountType(str, Enum):
- Liabilities/Equity/Revenue: Credit balance (credit - debit)
2. **`build_inventory_from_entry_lines()`** (Lines 56-117):
- Build CastleInventory from journal entry lines
- Build LibraInventory from journal entry lines
- Handles both sats and fiat currency tracking
- Accounts for account type when determining sign
@ -123,7 +123,7 @@ class AccountType(str, Enum):
- Checks both sats and fiat within tolerance
3. **`validate_receivable_entry()`** (Lines 180-199):
- Validates receivable (user owes castle) entries
- Validates receivable (user owes libra) entries
- Ensures positive amount
- Ensures revenue account type
@ -216,10 +216,10 @@ views_api.py → crud.py → core/
## File Structure
```
lnbits/extensions/castle/
lnbits/extensions/libra/
├── core/
│ ├── __init__.py # Module exports
│ ├── inventory.py # CastleInventory, CastlePosition
│ ├── inventory.py # LibraInventory, LibraPosition
│ ├── balance.py # BalanceCalculator
│ └── validation.py # Validation functions
├── crud.py # DB operations (refactored to use core/)
@ -230,22 +230,22 @@ lnbits/extensions/castle/
## Usage Examples
### Using CastleInventory
### Using LibraInventory
```python
from decimal import Decimal
from castle.core.inventory import CastleInventory, CastlePosition
from libra.core.inventory import LibraInventory, LibraPosition
# Create inventory
inv = CastleInventory()
inv = LibraInventory()
# Add positions
inv.add_position(CastlePosition(
inv.add_position(LibraPosition(
currency="SATS",
amount=Decimal("100000")
))
inv.add_position(CastlePosition(
inv.add_position(LibraPosition(
currency="SATS",
amount=Decimal("50000"),
cost_currency="EUR",
@ -264,7 +264,7 @@ data = inv.to_dict()
### Using BalanceCalculator
```python
from castle.core.balance import BalanceCalculator, AccountType
from libra.core.balance import BalanceCalculator, AccountType
# Calculate account balance
balance = BalanceCalculator.calculate_account_balance(
@ -297,7 +297,7 @@ is_valid = BalanceCalculator.check_balance_matches(
### Using Validation
```python
from castle.core.validation import validate_journal_entry, ValidationError
from libra.core.validation import validate_journal_entry, ValidationError
entry = {
"id": "abc123",
@ -320,8 +320,8 @@ except ValidationError as e:
## Testing Checklist
- [x] CastleInventory created and tested
- [x] CastlePosition addition works
- [x] LibraInventory created and tested
- [x] LibraPosition addition works
- [x] Inventory balance calculations work
- [x] BalanceCalculator account balance calculation works
- [x] BalanceCalculator inventory building works
@ -348,10 +348,10 @@ except ValidationError as e:
## Conclusion
Phase 3 successfully refactors Castle's accounting logic into a clean, testable core module. By following Beancount's architecture patterns, we've created:
Phase 3 successfully refactors Libra's accounting logic into a clean, testable core module. By following Beancount's architecture patterns, we've created:
- **Pure accounting logic** separated from database concerns
- **CastleInventory** for position tracking across currencies
- **LibraInventory** for position tracking across currencies
- **BalanceCalculator** for consistent balance calculations
- **Comprehensive validation** for data integrity

View file

@ -8,21 +8,21 @@
## Overview
The `sats-equivalent` metadata field is Castle's solution for **dual-currency tracking** in a fiat-denominated ledger. It preserves Bitcoin (satoshi) amounts alongside fiat currency amounts without violating accounting principles or creating multi-currency complexity in position balances.
The `sats-equivalent` metadata field is Libra's solution for **dual-currency tracking** in a fiat-denominated ledger. It preserves Bitcoin (satoshi) amounts alongside fiat currency amounts without violating accounting principles or creating multi-currency complexity in position balances.
### Quick Summary
- **Purpose**: Track Bitcoin/Lightning amounts in a EUR-denominated ledger
- **Location**: Beancount posting metadata (not position amounts)
- **Format**: String containing absolute satoshi amount (e.g., `"337096"`)
- **Primary Use**: Calculate user balances in satoshis (Castle's primary currency)
- **Primary Use**: Calculate user balances in satoshis (Libra's primary currency)
- **Key Principle**: Satoshis are for reference; EUR is the actual transaction currency
---
## The Problem: Dual-Currency Tracking
Castle needs to track both:
Libra needs to track both:
1. **Fiat amounts** (EUR, USD) - The actual transaction currency
2. **Bitcoin amounts** (satoshis) - The Lightning Network settlement currency
@ -34,7 +34,7 @@ Castle needs to track both:
- ❌ Complicate traditional accounting reconciliation
- ❌ Make fiat-based reporting difficult
**Castle's Philosophy**: Record transactions in their **actual currency**, with Bitcoin as supplementary data.
**Libra's Philosophy**: Record transactions in their **actual currency**, with Bitcoin as supplementary data.
---
@ -88,7 +88,7 @@ if fiat_currency and fiat_amount:
### Primary Use Case: User Balances
Castle's core function is tracking **who owes whom** in satoshis. The `sats-equivalent` metadata enables this.
Libra's core function is tracking **who owes whom** in satoshis. The `sats-equivalent` metadata enables this.
**Flow** (`fava_client.py:220-248`):
@ -147,7 +147,7 @@ SELECT account, sum(meta["sats-equivalent"]) WHERE account ~ 'User-5987ae95'
-- Error: BQL cannot access metadata
```
### Why Castle Accepts This Trade-off
### Why Libra Accepts This Trade-off
**Performance Analysis** (see `docs/BQL-BALANCE-QUERIES.md`):
1. **Caching solves the bottleneck**: 60-80% performance improvement from caching account/permission lookups
@ -196,9 +196,9 @@ See `docs/BQL-PRICE-NOTATION-SOLUTION.md` for full analysis.
**User Action**: "I paid €36.93 cash for groceries"
**Castle's Internal Representation**:
**Libra's Internal Representation**:
```python
# User provides or Castle calculates:
# User provides or Libra calculates:
fiat_amount = Decimal("36.93") # EUR
fiat_currency = "EUR"
amount_sats = 39669 # Calculated from exchange rate
@ -232,16 +232,16 @@ line = CreateEntryLine(
# - Apply sign: -36.93 is negative → sats = -39669
# - Accumulate: user_balance_sats += -39669
# Result: negative balance = Castle owes user
# Result: negative balance = Libra owes user
```
**User Balance Response**:
```json
{
"user_id": "5987ae95",
"balance": -39669, // Castle owes user 39,669 sats
"balance": -39669, // Libra owes user 39,669 sats
"fiat_balances": {
"EUR": "-36.93" // Castle owes user €36.93
"EUR": "-36.93" // Libra owes user €36.93
}
}
```
@ -306,7 +306,7 @@ The `sats-equivalent` is the **exact satoshi amount at transaction time**. It do
### 3. Separate Fiat and Sats Balances
Castle tracks TWO independent balances:
Libra tracks TWO independent balances:
- **Satoshi balance**: Sum of `sats-equivalent` metadata (primary)
- **Fiat balances**: Sum of EUR/USD position amounts (secondary)

View file

@ -1,4 +1,4 @@
# Castle UI Improvements Plan
# Libra UI Improvements Plan
**Date**: November 10, 2025
**Status**: 📋 **Planning Document**
@ -8,7 +8,7 @@
## Overview
Enhance the Castle permissions UI to showcase new bulk permission management and account sync features, making admin tasks faster and more intuitive.
Enhance the Libra permissions UI to showcase new bulk permission management and account sync features, making admin tasks faster and more intuitive.
---
@ -230,7 +230,7 @@ Enhance the Castle permissions UI to showcase new bulk permission management and
│ │
│ ⚠️ Warning: This will revoke ALL │
│ permissions for this user. They will │
│ immediately lose access to Castle. │
│ immediately lose access to Libra. │
│ │
│ Reason for Offboarding │
│ [Employee departure - last day] │
@ -257,13 +257,13 @@ Enhance the Castle permissions UI to showcase new bulk permission management and
├───────────────────────────────────────────┤
│ │
│ Sync accounts from your Beancount ledger │
│ to Castle database for permission mgmt. │
│ to Libra database for permission mgmt. │
│ │
│ Last Sync: 2 hours ago │
│ Status: ✅ Up to date │
│ │
│ Accounts in Beancount: 150 │
│ Accounts in Castle DB: 150 │
│ Accounts in Libra DB: 150 │
│ │
│ Options: │
│ ☐ Force full sync (re-check all) │
@ -509,7 +509,7 @@ permissions.html
syncStatus: {
lastSync: null,
beancountAccounts: 0,
castleAccounts: 0,
libraAccounts: 0,
status: 'idle'
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
# Castle Beancount Import Helper
# Libra Beancount Import Helper
Import Beancount ledger transactions into Castle accounting extension.
Import Beancount ledger transactions into Libra accounting extension.
## 📁 Files
@ -40,14 +40,14 @@ USER_MAPPINGS = {
### 3. Set API Key
```bash
export CASTLE_ADMIN_KEY="your_lnbits_admin_invoice_key"
export LIBRA_ADMIN_KEY="your_lnbits_admin_invoice_key"
export LNBITS_URL="http://localhost:5000" # Optional
```
## 📖 Usage
```bash
cd /path/to/castle/helper
cd /path/to/libra/helper
# Test with dry run
python import_beancount.py ledger.beancount --dry-run
@ -72,30 +72,30 @@ Your Beancount transactions must have an `Equity:<name>` account:
**Requirements:**
- Every transaction must have an `Equity:<name>` account
- Account names must match exactly what's in Castle
- Account names must match exactly what's in Libra
- The name after `Equity:` must be in `USER_MAPPINGS`
## 🔄 How It Works
1. **Loads rates** from `btc_eur_rates.csv`
2. **Loads accounts** from Castle API automatically
2. **Loads accounts** from Libra API automatically
3. **Maps users** - Extracts user name from `Equity:Name` accounts
4. **Parses** Beancount transactions
5. **Converts** EUR → sats using daily rate
6. **Uploads** to Castle with metadata
6. **Uploads** to Libra with metadata
## 📊 Example Output
```bash
$ python import_beancount.py ledger.beancount
======================================================================
🏰 Beancount to Castle Import Script
⚖️ Beancount to Libra Import Script
======================================================================
📊 Loaded 15 daily rates from btc_eur_rates.csv
Date range: 2025-07-01 to 2025-07-15
🏦 Loaded 28 accounts from Castle
🏦 Loaded 28 accounts from Libra
👥 User ID mappings:
- Pat → wallet_abc123
@ -112,15 +112,15 @@ $ python import_beancount.py ledger.beancount
📊 Summary: 25 succeeded, 0 failed, 0 skipped
======================================================================
✅ Successfully imported 25 transactions to Castle!
✅ Successfully imported 25 transactions to Libra!
```
## ❓ Troubleshooting
### "No account found in Castle"
**Error:** `No account found in Castle with name 'Expenses:XYZ'`
### "No account found in Libra"
**Error:** `No account found in Libra with name 'Expenses:XYZ'`
**Solution:** Create the account in Castle first with that exact name.
**Solution:** Create the account in Libra first with that exact name.
### "No user ID mapping found"
**Error:** `No user ID mapping found for 'Pat'`

View file

@ -1,11 +1,11 @@
#!/usr/bin/env python3
"""
Beancount to Castle Import Script
Beancount to Libra Import Script
NOTE: This script is for ONE-OFF MIGRATION purposes only.
Now that Castle uses Fava/Beancount as the single source of truth,
the data flow is: Castle Fava/Beancount (not the reverse).
Now that Libra uses Fava/Beancount as the single source of truth,
the data flow is: Libra Fava/Beancount (not the reverse).
This script was used for initial data import from existing Beancount files.
@ -14,7 +14,7 @@ Beancount to Castle Import Script
- REPURPOSE for bidirectional sync if that becomes a requirement
- ARCHIVE to misc-docs/old-helpers/ if keeping for reference
Imports Beancount ledger transactions into Castle accounting extension.
Imports Beancount ledger transactions into Libra accounting extension.
Reads daily BTC/EUR rates from btc_eur_rates.csv in the same directory.
Usage:
@ -35,14 +35,14 @@ from typing import Dict, Optional
# LNbits URL and API Key
LNBITS_URL = os.environ.get("LNBITS_URL", "http://localhost:5000")
ADMIN_API_KEY = os.environ.get("CASTLE_ADMIN_KEY", "48d787d862484a6c89d6a557b4d5be9d")
ADMIN_API_KEY = os.environ.get("LIBRA_ADMIN_KEY", "48d787d862484a6c89d6a557b4d5be9d")
# Rates CSV file (looks in same directory as this script)
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
RATES_CSV_FILE = os.path.join(SCRIPT_DIR, "btc_eur_rates.csv")
# User ID mappings: Equity account name -> Castle user ID (wallet ID)
# TODO: Update these with your actual Castle user/wallet IDs
# User ID mappings: Equity account name -> Libra user ID (wallet ID)
# TODO: Update these with your actual Libra user/wallet IDs
USER_MAPPINGS = {
"Pat": "75be145a42884b22b60bf97510ed46e3",
"Coco": "375ec158ceca46de86cf6561ca20f881",
@ -116,7 +116,7 @@ class RateLookup:
# ===== ACCOUNT LOOKUP =====
class AccountLookup:
"""Fetch and lookup Castle accounts from API"""
"""Fetch and lookup Libra accounts from API"""
def __init__(self, lnbits_url: str, api_key: str):
self.accounts = {} # name -> account_id
@ -125,8 +125,8 @@ class AccountLookup:
self._fetch_accounts(lnbits_url, api_key)
def _fetch_accounts(self, lnbits_url: str, api_key: str):
"""Fetch all accounts from Castle API"""
url = f"{lnbits_url}/castle/api/v1/accounts"
"""Fetch all accounts from Libra API"""
url = f"{lnbits_url}/libra/api/v1/accounts"
headers = {"X-Api-Key": api_key}
try:
@ -153,28 +153,28 @@ class AccountLookup:
self.accounts_by_user[user_id] = {}
self.accounts_by_user[user_id][account_type] = account_id
print(f"🏦 Loaded {len(self.accounts)} accounts from Castle")
print(f"🏦 Loaded {len(self.accounts)} accounts from Libra")
except requests.RequestException as e:
raise ConnectionError(f"Failed to fetch accounts from Castle API: {e}")
raise ConnectionError(f"Failed to fetch accounts from Libra API: {e}")
def get_account_id(self, account_name: str) -> Optional[str]:
"""
Get Castle account ID for a Beancount account name.
Get Libra account ID for a Beancount account name.
Special handling for user-specific accounts:
- "Liabilities:Payable:Pat" -> looks up Pat's user_id and finds their Castle payable account
- "Assets:Receivable:Pat" -> looks up Pat's user_id and finds their Castle receivable account
- "Equity:Pat" -> looks up Pat's user_id and finds their Castle equity account
- "Liabilities:Payable:Pat" -> looks up Pat's user_id and finds their Libra payable account
- "Assets:Receivable:Pat" -> looks up Pat's user_id and finds their Libra receivable account
- "Equity:Pat" -> looks up Pat's user_id and finds their Libra equity account
Args:
account_name: Beancount account name (e.g., "Expenses:Food:Supplies", "Liabilities:Payable:Pat", "Assets:Receivable:Pat", "Equity:Pat")
Returns:
Castle account UUID or None if not found
Libra account UUID or None if not found
"""
# Check if this is a Liabilities:Payable:<name> account
# Map Beancount Liabilities:Payable:Pat to Castle Liabilities:Payable:User-<id>
# Map Beancount Liabilities:Payable:Pat to Libra Liabilities:Payable:User-<id>
if account_name.startswith("Liabilities:Payable:"):
user_name = extract_user_from_user_account(account_name)
if user_name:
@ -182,7 +182,7 @@ class AccountLookup:
user_id = USER_MAPPINGS.get(user_name)
if user_id:
# Find this user's liability (payable) account
# This is the Liabilities:Payable:User-<id> account in Castle
# This is the Liabilities:Payable:User-<id> account in Libra
if user_id in self.accounts_by_user:
liability_account_id = self.accounts_by_user[user_id].get('liability')
if liability_account_id:
@ -196,7 +196,7 @@ class AccountLookup:
)
# Check if this is an Assets:Receivable:<name> account
# Map Beancount Assets:Receivable:Pat to Castle Assets:Receivable:User-<id>
# Map Beancount Assets:Receivable:Pat to Libra Assets:Receivable:User-<id>
elif account_name.startswith("Assets:Receivable:"):
user_name = extract_user_from_user_account(account_name)
if user_name:
@ -204,7 +204,7 @@ class AccountLookup:
user_id = USER_MAPPINGS.get(user_name)
if user_id:
# Find this user's asset (receivable) account
# This is the Assets:Receivable:User-<id> account in Castle
# This is the Assets:Receivable:User-<id> account in Libra
if user_id in self.accounts_by_user:
asset_account_id = self.accounts_by_user[user_id].get('asset')
if asset_account_id:
@ -218,7 +218,7 @@ class AccountLookup:
)
# Check if this is an Equity:<name> account
# Map Beancount Equity:Pat to Castle Equity:User-<id>
# Map Beancount Equity:Pat to Libra Equity:User-<id>
elif account_name.startswith("Equity:"):
user_name = extract_user_from_user_account(account_name)
if user_name:
@ -226,7 +226,7 @@ class AccountLookup:
user_id = USER_MAPPINGS.get(user_name)
if user_id:
# Find this user's equity account
# This is the Equity:User-<id> account in Castle
# This is the Equity:User-<id> account in Libra
if user_id in self.accounts_by_user:
equity_account_id = self.accounts_by_user[user_id].get('equity')
if equity_account_id:
@ -235,7 +235,7 @@ class AccountLookup:
# If not found, provide helpful error
raise ValueError(
f"User '{user_name}' (ID: {user_id}) does not have an equity account.\n"
f"Equity eligibility must be enabled for this user in Castle.\n"
f"Equity eligibility must be enabled for this user in Libra.\n"
f"Please enable equity for user ID: {user_id}"
)
@ -282,7 +282,7 @@ def eur_to_sats(eur_amount: Decimal, btc_eur_rate: float) -> int:
def build_metadata(eur_amount: Decimal, btc_eur_rate: float) -> dict:
"""
Build metadata dict for Castle entry line.
Build metadata dict for Libra entry line.
The API will extract fiat_currency and fiat_amount and use them
to create proper EUR-based postings with SATS in metadata.
@ -441,13 +441,13 @@ def determine_user_id(postings: list) -> Optional[str]:
# No user-specific account found - this shouldn't happen for typical transactions
return None
# ===== CASTLE CONVERTER =====
# ===== LIBRA CONVERTER =====
def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: AccountLookup) -> dict:
def convert_to_libra_entry(parsed: dict, btc_eur_rate: float, account_lookup: AccountLookup) -> dict:
"""
Convert parsed Beancount transaction to Castle format.
Convert parsed Beancount transaction to Libra format.
Sends SATS amounts with fiat metadata. The Castle API will automatically
Sends SATS amounts with fiat metadata. The Libra API will automatically
convert to EUR-based postings with SATS stored in metadata.
"""
@ -469,8 +469,8 @@ def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: A
account_id = account_lookup.get_account_id(posting['account'])
if not account_id:
raise ValueError(
f"No account found in Castle with name '{posting['account']}'.\n"
f"Please create this account in Castle first."
f"No account found in Libra with name '{posting['account']}'.\n"
f"Please create this account in Libra first."
)
eur_amount = posting['eur_amount']
@ -510,7 +510,7 @@ def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: A
# ===== API UPLOAD =====
def upload_entry(entry: dict, api_key: str, dry_run: bool = False) -> dict:
"""Upload journal entry to Castle API"""
"""Upload journal entry to Libra API"""
if dry_run:
print(f"\n[DRY RUN] Entry preview:")
print(f" Description: {entry['description']}")
@ -525,7 +525,7 @@ def upload_entry(entry: dict, api_key: str, dry_run: bool = False) -> dict:
print(f" Balance check: {total_sats} (should be 0)")
return {"id": "dry-run"}
url = f"{LNBITS_URL}/castle/api/v1/entries"
url = f"{LNBITS_URL}/libra/api/v1/entries"
headers = {
"X-Api-Key": api_key,
"Content-Type": "application/json"
@ -551,7 +551,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
# Validate configuration
if not ADMIN_API_KEY:
print("❌ Error: CASTLE_ADMIN_KEY not set!")
print("❌ Error: LIBRA_ADMIN_KEY not set!")
print(" Set it as environment variable or update ADMIN_API_KEY in the script.")
return
@ -562,7 +562,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
print(f"❌ Error loading rates: {e}")
return
# Load accounts from Castle
# Load accounts from Libra
try:
account_lookup = AccountLookup(LNBITS_URL, ADMIN_API_KEY)
except (ConnectionError, ValueError) as e:
@ -574,7 +574,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
for name, user_id in USER_MAPPINGS.items():
has_equity = user_id in account_lookup.accounts_by_user and 'equity' in account_lookup.accounts_by_user[user_id]
status = "" if has_equity else ""
print(f" {status} {name}{user_id} {'(has equity account)' if has_equity else '(NO EQUITY ACCOUNT - create in Castle!)'}")
print(f" {status} {name}{user_id} {'(has equity account)' if has_equity else '(NO EQUITY ACCOUNT - create in Libra!)'}")
# Read beancount file
if not os.path.exists(beancount_file):
@ -612,8 +612,8 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
if not btc_eur_rate:
raise ValueError(f"No BTC/EUR rate found for {parsed['date'].date()}")
castle_entry = convert_to_castle_entry(parsed, btc_eur_rate, account_lookup)
result = upload_entry(castle_entry, ADMIN_API_KEY, dry_run)
libra_entry = convert_to_libra_entry(parsed, btc_eur_rate, account_lookup)
result = upload_entry(libra_entry, ADMIN_API_KEY, dry_run)
# Get user name for display
user_name = None
@ -643,7 +643,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
print(f" {item}")
if success_count > 0 and not dry_run:
print(f"\n✅ Successfully imported {success_count} transactions to Castle!")
print(f"\n✅ Successfully imported {success_count} transactions to Libra!")
print(f"\n💡 Note: Transactions are stored in EUR with SATS in metadata.")
print(f" Check Fava to see the imported entries.")
@ -653,7 +653,7 @@ if __name__ == "__main__":
import sys
print("=" * 70)
print("🏰 Beancount to Castle Import Script")
print("⚖️ Beancount to Libra Import Script")
print("=" * 70)
if len(sys.argv) < 2:
@ -664,7 +664,7 @@ if __name__ == "__main__":
print("\nConfiguration:")
print(f" LNBITS_URL: {LNBITS_URL}")
print(f" RATES_CSV: {RATES_CSV_FILE}")
print(f" API Key set: {'Yes' if ADMIN_API_KEY else 'No (set CASTLE_ADMIN_KEY env var)'}")
print(f" API Key set: {'Yes' if ADMIN_API_KEY else 'No (set LIBRA_ADMIN_KEY env var)'}")
sys.exit(1)
beancount_file = sys.argv[1]

View file

@ -1,9 +1,9 @@
{
"repos": [
{
"id": "castle",
"id": "libra",
"organisation": "lnbits",
"repository": "castle"
"repository": "libra"
}
]
}

View file

@ -1,8 +1,8 @@
"""
Castle Extension Database Migrations
Libra Extension Database Migrations
This file contains a single squashed migration that creates the complete
database schema for the Castle extension.
database schema for the Libra extension.
MIGRATION HISTORY:
This is a squashed migration that combines m001-m016 from the original
@ -39,19 +39,19 @@ Original migration sequence (Nov 2025):
async def m001_initial(db):
"""
Initial Castle database schema (squashed from m001-m016).
Initial Libra database schema (squashed from m001-m016).
Creates complete database structure for Castle accounting extension:
Creates complete database structure for Libra accounting extension:
- Accounts: Chart of accounts with hierarchical Beancount-style names
- Extension settings: Castle-wide configuration
- Extension settings: Libra-wide configuration
- User wallet settings: Per-user wallet configuration
- Manual payment requests: User-submitted payment requests to Castle
- Manual payment requests: User-submitted payment requests to Libra
- Balance assertions: Reconciliation and balance checking
- User equity status: Equity contribution eligibility
- Account permissions: Granular access control
Note: Journal entries are managed by Fava/Beancount (external source of truth).
Castle submits entries to Fava and queries Fava for journal data.
Libra submits entries to Fava and queries Fava for journal data.
"""
# =========================================================================
@ -89,15 +89,15 @@ async def m001_initial(db):
# =========================================================================
# EXTENSION SETTINGS TABLE
# =========================================================================
# Castle-wide configuration settings
# Libra-wide configuration settings
await db.execute(
f"""
CREATE TABLE extension_settings (
id TEXT NOT NULL PRIMARY KEY,
castle_wallet_id TEXT,
libra_wallet_id TEXT,
fava_url TEXT NOT NULL DEFAULT 'http://localhost:3333',
fava_ledger_slug TEXT NOT NULL DEFAULT 'castle-ledger',
fava_ledger_slug TEXT NOT NULL DEFAULT 'libra-ledger',
fava_timeout REAL NOT NULL DEFAULT 10.0,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
@ -122,7 +122,7 @@ async def m001_initial(db):
# =========================================================================
# MANUAL PAYMENT REQUESTS TABLE
# =========================================================================
# User-submitted payment requests to Castle (reviewed by admins)
# User-submitted payment requests to Libra (reviewed by admins)
await db.execute(
f"""
@ -240,7 +240,7 @@ async def m001_initial(db):
# ACCOUNT PERMISSIONS TABLE
# =========================================================================
# Granular access control for accounts
# Permission types: read, submit_expense, manage
# Permission types: read, submit_expense, submit_income, manage
# Supports hierarchical inheritance (parent account permissions cascade)
await db.execute(
@ -362,7 +362,7 @@ async def m003_add_account_is_virtual(db):
Add is_virtual field to accounts table for virtual parent accounts.
Virtual parent accounts:
- Exist only in Castle DB (metadata-only, not in Beancount)
- Exist only in Libra DB (metadata-only, not in Beancount)
- Used solely for permission inheritance
- Allow granting permissions on top-level accounts like "Expenses", "Assets"
- Are not synced to/from Beancount

View file

@ -48,6 +48,17 @@ class CreateAccount(BaseModel):
is_virtual: bool = False # Set to True to create virtual parent account
class CreateChartAccount(BaseModel):
"""Admin-created chart-of-accounts entry written to accounts/chart.beancount."""
name: str # Full hierarchical account name, e.g. "Expenses:Services:Domain"
# Optional currency constraint. Omitted by the UI: an Open directive needs
# no currency list, and constraining it would reject postings in other
# currencies (the CAD/GBP/JPY bean-check errors we saw on user accounts).
# None → unconstrained Open; a list → explicit constraint for API callers.
currencies: Optional[list[str]] = None
description: Optional[str] = None
class EntryLine(BaseModel):
id: str
journal_entry_id: str
@ -87,9 +98,19 @@ class CreateJournalEntry(BaseModel):
class UserBalance(BaseModel):
user_id: str
balance: int # positive = castle owes user, negative = user owes castle
balance: int # positive = libra owes user, negative = user owes libra
accounts: list[Account] = []
# Per-account breakdown surfaced from get_user_balance_bql so UIs (libra
# extension dashboard + webapp) can render Payable / Receivable / Credit
# as distinct line items. Each entry: {"account": str, "sats": int,
# "eur": Decimal}. Wired up for libra-#41's display contract.
account_balances: list[dict] = []
fiat_balances: dict[str, Decimal] = {} # e.g. {"EUR": Decimal("250.0"), "USD": Decimal("100.0")}
# Lifetime totals (original entries only; not net of reconciliation)
total_expenses_sats: int = 0
total_expenses_fiat: dict[str, Decimal] = {}
total_income_sats: int = 0
total_income_fiat: dict[str, Decimal] = {}
class ExpenseEntry(BaseModel):
@ -98,7 +119,7 @@ class ExpenseEntry(BaseModel):
description: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
expense_account: str # account name or ID
is_equity: bool = False # True = equity contribution, False = liability (castle owes user)
is_equity: bool = False # True = equity contribution, False = liability (libra owes user)
user_wallet: str
reference: Optional[str] = None
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
@ -111,7 +132,7 @@ class ReceivableEntry(BaseModel):
description: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
revenue_account: str # account name or ID
user_id: str # The user_id (not wallet_id) of the user who owes the castle
user_id: str # The user_id (not wallet_id) of the user who owes the libra
reference: Optional[str] = None
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code
@ -127,14 +148,32 @@ class RevenueEntry(BaseModel):
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code
class CastleSettings(BaseModel):
"""Settings for the Castle extension"""
class IncomeEntry(BaseModel):
"""Helper model for user-facing income/revenue submission (pending approval).
castle_wallet_id: Optional[str] = None # The wallet ID that represents the Castle
The user records that they personally received money on the entity's
behalf so the postings are DR Assets:Receivable:User-{id} / CR
revenue_account. The user now owes the entity until they settle via
the existing /settle-receivable flow. Symmetric with ExpenseEntry,
which credits Liabilities:Payable:User-{id} (entity owes user).
"""
description: str
amount: Decimal # Fiat amount in the specified currency
revenue_account: str # Income/Revenue account name or ID
currency: str # Required: fiat currency code (EUR, USD, etc.)
reference: Optional[str] = None
entry_date: Optional[datetime] = None
class LibraSettings(BaseModel):
"""Settings for the Libra extension"""
libra_wallet_id: Optional[str] = None # The wallet ID that represents the Libra
# Fava/Beancount integration - ALL accounting is done via Fava
fava_url: str = "http://localhost:3333" # Base URL of Fava server
fava_ledger_slug: str = "castle-ledger" # Ledger identifier in Fava URL
fava_ledger_slug: str = "libra-ledger" # Ledger identifier in Fava URL
fava_timeout: float = 10.0 # Request timeout in seconds
updated_at: datetime = Field(default_factory=lambda: datetime.now())
@ -144,7 +183,7 @@ class CastleSettings(BaseModel):
return True
class UserCastleSettings(CastleSettings):
class UserLibraSettings(LibraSettings):
"""User-specific settings (stored with user_id)"""
id: str
@ -164,7 +203,7 @@ class StoredUserWalletSettings(UserWalletSettings):
class ManualPaymentRequest(BaseModel):
"""Manual payment request from user to castle"""
"""Manual payment request from user to libra"""
id: str
user_id: str
@ -173,7 +212,7 @@ class ManualPaymentRequest(BaseModel):
status: str = "pending" # pending, approved, rejected
created_at: datetime
reviewed_at: Optional[datetime] = None
reviewed_by: Optional[str] = None # user_id of castle admin who reviewed
reviewed_by: Optional[str] = None # user_id of libra admin who reviewed
journal_entry_id: Optional[str] = None # set when approved
@ -198,7 +237,7 @@ class RecordPayment(BaseModel):
class SettleReceivable(BaseModel):
"""Manually settle a receivable (user pays castle in person)"""
"""Manually settle a receivable (user pays libra in person)"""
user_id: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
@ -213,7 +252,7 @@ class SettleReceivable(BaseModel):
class PayUser(BaseModel):
"""Pay a user (castle pays user for expense/liability)"""
"""Pay a user (libra pays user for expense/liability)"""
user_id: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
@ -295,6 +334,7 @@ class PermissionType(str, Enum):
"""Types of permissions for account access"""
READ = "read" # Can view account and its balance
SUBMIT_EXPENSE = "submit_expense" # Can submit expenses to this account
SUBMIT_INCOME = "submit_income" # Can submit income/revenue to this account
MANAGE = "manage" # Can modify account (admin level)

View file

@ -1,5 +1,5 @@
{
"name": "castle",
"name": "libra",
"version": "0.0.2",
"description": "Accounting for a collective entity",
"main": "index.js",

View file

@ -361,7 +361,7 @@ async def get_permission_analytics() -> dict:
"""
SELECT ap.*, a.name as account_name
FROM account_permissions ap
JOIN castle_accounts a ON ap.account_id = a.id
JOIN libra_accounts a ON ap.account_id = a.id
WHERE ap.expires_at IS NOT NULL
AND ap.expires_at > :now
AND ap.expires_at <= :seven_days
@ -385,7 +385,7 @@ async def get_permission_analytics() -> dict:
top_accounts_result = await db.fetchall(
"""
SELECT a.name, COUNT(ap.id) as permission_count
FROM castle_accounts a
FROM libra_accounts a
LEFT JOIN account_permissions ap ON a.id = ap.account_id
GROUP BY a.id, a.name
HAVING COUNT(ap.id) > 0

View file

@ -1,28 +1,45 @@
from .crud import (
create_castle_settings,
create_libra_settings,
create_user_wallet_settings,
get_castle_settings,
get_libra_settings,
get_or_create_user_account,
get_user_wallet_settings,
update_castle_settings,
update_libra_settings,
update_user_wallet_settings,
)
from .models import AccountType, CastleSettings, UserWalletSettings
from .models import AccountType, LibraSettings, UserWalletSettings
async def get_settings(user_id: str) -> CastleSettings:
settings = await get_castle_settings(user_id)
async def get_settings(user_id: str) -> LibraSettings:
settings = await get_libra_settings(user_id)
if not settings:
settings = await create_castle_settings(user_id, CastleSettings())
settings = await create_libra_settings(user_id, LibraSettings())
return settings
async def update_settings(user_id: str, data: CastleSettings) -> CastleSettings:
settings = await get_castle_settings(user_id)
async def update_settings(user_id: str, data: LibraSettings) -> LibraSettings:
from loguru import logger
from .fava_client import init_fava_client
settings = await get_libra_settings(user_id)
if not settings:
settings = await create_castle_settings(user_id, data)
settings = await create_libra_settings(user_id, data)
else:
settings = await update_castle_settings(user_id, data)
settings = await update_libra_settings(user_id, data)
# Reinitialize Fava client with new settings
try:
init_fava_client(
fava_url=settings.fava_url,
ledger_slug=settings.fava_ledger_slug,
timeout=settings.fava_timeout,
)
logger.info(
f"Fava client reinitialized: {settings.fava_url}/{settings.fava_ledger_slug}"
)
except Exception as e:
logger.error(f"Failed to reinitialize Fava client: {e}")
return settings

View file

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Before After
Before After

View file

@ -31,8 +31,10 @@ window.app = Vue.createApp({
userInfo: null, // User information including equity eligibility
isAdmin: false,
isSuperUser: false,
castleWalletConfigured: false,
settingsLoaded: false, // Flag to prevent race conditions on toolbar buttons
libraWalletConfigured: false,
userWalletConfigured: false,
syncingAccounts: false,
currentExchangeRate: null, // BTC/EUR rate (sats per EUR)
expenseDialog: {
show: false,
@ -56,7 +58,10 @@ window.app = Vue.createApp({
},
settingsDialog: {
show: false,
castleWalletId: '',
libraWalletId: '',
favaUrl: 'http://localhost:3333',
favaLedgerSlug: 'libra-ledger',
favaTimeout: 10.0,
loading: false
},
userWalletDialog: {
@ -64,6 +69,13 @@ window.app = Vue.createApp({
userWalletId: '',
loading: false
},
addAccountDialog: {
show: false,
rootType: 'Expenses',
subPath: '',
description: '',
loading: false
},
receivableDialog: {
show: false,
selectedUser: '',
@ -203,8 +215,8 @@ window.app = Vue.createApp({
accountTypeOptions() {
return [
{ label: 'All Types', value: null },
{ label: 'Receivable (User owes Castle)', value: 'asset' },
{ label: 'Payable (Castle owes User)', value: 'liability' },
{ label: 'Receivable (User owes Libra)', value: 'asset' },
{ label: 'Payable (Libra owes User)', value: 'liability' },
{ label: 'Equity (User Balance)', value: 'equity' }
]
},
@ -281,6 +293,16 @@ window.app = Vue.createApp({
})
return options
},
accountRootTypes() {
// The five Beancount root account types — the only valid parents.
// Mirrors the server's _VALID_ACCOUNT_PREFIXES.
return ['Assets', 'Liabilities', 'Equity', 'Income', 'Expenses']
},
addAccountFullName() {
const sub = (this.addAccountDialog.subPath || '').trim().replace(/^:+|:+$/g, '')
if (!this.addAccountDialog.rootType || !sub) return ''
return `${this.addAccountDialog.rootType}:${sub}`
},
userOptions() {
const options = []
this.users.forEach(user => {
@ -313,7 +335,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/balance',
'/libra/api/v1/balance',
this.g.user.wallets[0].inkey
)
this.balance = response.data
@ -336,7 +358,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/balances/all',
'/libra/api/v1/balances/all',
this.g.user.wallets[0].adminkey
)
this.allUserBalances = response.data
@ -384,7 +406,7 @@ window.app = Vue.createApp({
const response = await LNbits.api.request(
'GET',
`/castle/api/v1/entries/user?${queryParams}`,
`/libra/api/v1/entries/user?${queryParams}`,
this.g.user.wallets[0].inkey
)
@ -453,7 +475,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/accounts?filter_by_user=true&exclude_virtual=true',
'/libra/api/v1/accounts?filter_by_user=true&exclude_virtual=true',
this.g.user.wallets[0].inkey
)
this.accounts = response.data
@ -467,7 +489,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/currencies',
'/libra/api/v1/currencies',
this.g.user.wallets[0].inkey
)
this.currencies = response.data
@ -479,7 +501,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/users',
'/libra/api/v1/users',
this.g.user.wallets[0].adminkey
)
this.users = response.data
@ -491,7 +513,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/user/info',
'/libra/api/v1/user/info',
this.g.user.wallets[0].inkey
)
this.userInfo = response.data
@ -505,25 +527,28 @@ window.app = Vue.createApp({
// Try with admin key first to check settings
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/settings',
'/libra/api/v1/settings',
this.g.user.wallets[0].inkey
)
this.settings = response.data
this.castleWalletConfigured = !!(this.settings && this.settings.castle_wallet_id)
this.libraWalletConfigured = !!(this.settings && this.settings.libra_wallet_id)
// Check if user is super user by seeing if they can access admin features
this.isSuperUser = this.g.user.super_user || false
this.isAdmin = this.g.user.admin || this.isSuperUser
} catch (error) {
// Settings not available
this.castleWalletConfigured = false
this.libraWalletConfigured = false
} finally {
// Mark settings as loaded to enable toolbar buttons
this.settingsLoaded = true
}
},
async loadUserWallet() {
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/user/wallet',
'/libra/api/v1/user/wallet',
this.g.user.wallets[0].inkey
)
this.userWalletSettings = response.data
@ -532,8 +557,86 @@ window.app = Vue.createApp({
this.userWalletConfigured = false
}
},
async syncAccounts() {
this.syncingAccounts = true
try {
const {data} = await LNbits.api.request(
'POST',
'/libra/api/v1/admin/accounts/sync',
this.g.user.wallets[0].adminkey
)
const errors = (data?.errors || []).length
const message = `Synced: ${data?.accounts_added ?? 0} added, ` +
`${data?.accounts_reactivated ?? 0} reactivated, ` +
`${data?.accounts_deactivated ?? 0} deactivated, ` +
`${data?.virtual_parents_created ?? 0} virtual parents` +
(errors ? `, ${errors} errors` : '')
this.$q.notify({
type: errors ? 'warning' : 'positive',
message,
timeout: errors ? 8000 : 4000
})
await this.loadAccounts()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.syncingAccounts = false
}
},
showAddAccountDialog() {
this.addAccountDialog.rootType = 'Expenses'
this.addAccountDialog.subPath = ''
this.addAccountDialog.description = ''
this.addAccountDialog.show = true
},
async submitAddAccount() {
const name = this.addAccountFullName
if (!name) {
this.$q.notify({type: 'warning', message: 'Enter a sub-account name'})
return
}
// Each segment under the root must be a valid Beancount account
// component (core/account.py ACC_COMP_NAME_RE): starts with an uppercase
// letter or digit, then letters/digits/hyphens (Unicode letters allowed).
const badSegment = name.split(':').slice(1).find(
seg => !/^[\p{Lu}\p{Nd}][\p{L}\p{Nd}-]*$/u.test(seg)
)
if (badSegment !== undefined) {
this.$q.notify({
type: 'warning',
message: `Invalid segment "${badSegment}" — letters, digits and hyphens only, starting with a capital letter or digit`
})
return
}
this.addAccountDialog.loading = true
try {
const {data} = await LNbits.api.request(
'POST',
'/libra/api/v1/admin/accounts',
this.g.user.wallets[0].adminkey,
{
name,
description: this.addAccountDialog.description || null
}
)
this.$q.notify({
type: 'positive',
message: `Account ${data.account_name} created` +
(data.synced_to_libra_db ? '' : ' (sync pending)')
})
this.addAccountDialog.show = false
await this.loadAccounts()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.addAccountDialog.loading = false
}
},
showSettingsDialog() {
this.settingsDialog.castleWalletId = this.settings?.castle_wallet_id || ''
this.settingsDialog.libraWalletId = this.settings?.libra_wallet_id || ''
this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333'
this.settingsDialog.favaLedgerSlug = this.settings?.fava_ledger_slug || 'libra-ledger'
this.settingsDialog.favaTimeout = this.settings?.fava_timeout || 10.0
this.settingsDialog.show = true
},
showUserWalletDialog() {
@ -541,10 +644,18 @@ window.app = Vue.createApp({
this.userWalletDialog.show = true
},
async submitSettings() {
if (!this.settingsDialog.castleWalletId) {
if (!this.settingsDialog.libraWalletId) {
this.$q.notify({
type: 'warning',
message: 'Castle Wallet ID is required'
message: 'Libra Wallet ID is required'
})
return
}
if (!this.settingsDialog.favaUrl) {
this.$q.notify({
type: 'warning',
message: 'Fava URL is required'
})
return
}
@ -553,10 +664,13 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'PUT',
'/castle/api/v1/settings',
'/libra/api/v1/settings',
this.g.user.wallets[0].adminkey,
{
castle_wallet_id: this.settingsDialog.castleWalletId
libra_wallet_id: this.settingsDialog.libraWalletId,
fava_url: this.settingsDialog.favaUrl,
fava_ledger_slug: this.settingsDialog.favaLedgerSlug || 'libra-ledger',
fava_timeout: parseFloat(this.settingsDialog.favaTimeout) || 10.0
}
)
this.$q.notify({
@ -565,7 +679,7 @@ window.app = Vue.createApp({
})
this.settingsDialog.show = false
await this.loadSettings()
// Reload user wallet to reflect castle wallet for super user
// Reload user wallet to reflect libra wallet for super user
if (this.isSuperUser) {
await this.loadUserWallet()
}
@ -588,7 +702,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'PUT',
'/castle/api/v1/user/wallet',
'/libra/api/v1/user/wallet',
this.g.user.wallets[0].inkey,
{
user_wallet_id: this.userWalletDialog.userWalletId
@ -611,7 +725,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'POST',
'/castle/api/v1/entries/expense',
'/libra/api/v1/entries/expense',
this.g.user.wallets[0].inkey,
{
description: this.expenseDialog.description,
@ -648,10 +762,10 @@ window.app = Vue.createApp({
}
try {
// Generate an invoice on the Castle wallet
// Generate an invoice on the Libra wallet
const response = await LNbits.api.request(
'POST',
'/castle/api/v1/generate-payment-invoice',
'/libra/api/v1/generate-payment-invoice',
this.g.user.wallets[0].inkey,
{
amount: this.payDialog.amount
@ -697,7 +811,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'POST',
'/castle/api/v1/record-payment',
'/libra/api/v1/record-payment',
this.g.user.wallets[0].inkey,
{
payment_hash: paymentHash
@ -740,15 +854,15 @@ window.app = Vue.createApp({
},
showManualPaymentOption() {
// This is for when user wants to pay their debt manually
// For now, just notify them to contact castle
// For now, just notify them to contact libra
this.$q.notify({
type: 'info',
message: 'Please contact Castle directly to arrange manual payment.',
message: 'Please contact Libra directly to arrange manual payment.',
timeout: 3000
})
},
showManualPaymentDialog() {
// This is for when Castle owes user and they want to request manual payment
// This is for when Libra owes user and they want to request manual payment
this.manualPaymentDialog.amount = Math.abs(this.balance.balance)
this.manualPaymentDialog.description = ''
this.manualPaymentDialog.show = true
@ -758,7 +872,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'POST',
'/castle/api/v1/manual-payment-request',
'/libra/api/v1/manual-payment-request',
this.g.user.wallets[0].inkey,
{
amount: this.manualPaymentDialog.amount,
@ -783,8 +897,8 @@ window.app = Vue.createApp({
try {
// If super user, load all requests; otherwise load user's own requests
const endpoint = this.isSuperUser
? '/castle/api/v1/manual-payment-requests/all'
: '/castle/api/v1/manual-payment-requests'
? '/libra/api/v1/manual-payment-requests/all'
: '/libra/api/v1/manual-payment-requests'
const key = this.isSuperUser
? this.g.user.wallets[0].adminkey
: this.g.user.wallets[0].inkey
@ -807,7 +921,7 @@ window.app = Vue.createApp({
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/entries/pending',
'/libra/api/v1/entries/pending',
this.g.user.wallets[0].adminkey
)
this.pendingExpenses = response.data
@ -819,7 +933,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'POST',
`/castle/api/v1/manual-payment-requests/${requestId}/approve`,
`/libra/api/v1/manual-payment-requests/${requestId}/approve`,
this.g.user.wallets[0].adminkey
)
this.$q.notify({
@ -837,7 +951,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'POST',
`/castle/api/v1/manual-payment-requests/${requestId}/reject`,
`/libra/api/v1/manual-payment-requests/${requestId}/reject`,
this.g.user.wallets[0].adminkey
)
this.$q.notify({
@ -853,7 +967,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'POST',
`/castle/api/v1/entries/${entryId}/approve`,
`/libra/api/v1/entries/${entryId}/approve`,
this.g.user.wallets[0].adminkey
)
this.$q.notify({
@ -872,7 +986,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'POST',
`/castle/api/v1/entries/${entryId}/reject`,
`/libra/api/v1/entries/${entryId}/reject`,
this.g.user.wallets[0].adminkey
)
this.$q.notify({
@ -891,7 +1005,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/assertions',
'/libra/api/v1/assertions',
this.g.user.wallets[0].adminkey
)
this.balanceAssertions = response.data
@ -917,7 +1031,7 @@ window.app = Vue.createApp({
await LNbits.api.request(
'POST',
'/castle/api/v1/assertions',
'/libra/api/v1/assertions',
this.g.user.wallets[0].adminkey,
payload
)
@ -966,7 +1080,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'POST',
`/castle/api/v1/assertions/${assertionId}/check`,
`/libra/api/v1/assertions/${assertionId}/check`,
this.g.user.wallets[0].adminkey
)
@ -985,7 +1099,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'DELETE',
`/castle/api/v1/assertions/${assertionId}`,
`/libra/api/v1/assertions/${assertionId}`,
this.g.user.wallets[0].adminkey
)
@ -1014,7 +1128,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/reconciliation/summary',
'/libra/api/v1/reconciliation/summary',
this.g.user.wallets[0].adminkey
)
this.reconciliation.summary = response.data
@ -1028,7 +1142,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/reconciliation/discrepancies',
'/libra/api/v1/reconciliation/discrepancies',
this.g.user.wallets[0].adminkey
)
this.reconciliation.discrepancies = response.data
@ -1041,7 +1155,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'POST',
'/castle/api/v1/reconciliation/check-all',
'/libra/api/v1/reconciliation/check-all',
this.g.user.wallets[0].adminkey
)
@ -1095,7 +1209,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'POST',
'/castle/api/v1/entries/receivable',
'/libra/api/v1/entries/receivable',
this.g.user.wallets[0].adminkey,
{
description: this.receivableDialog.description,
@ -1138,7 +1252,7 @@ window.app = Vue.createApp({
this.receivableDialog.currency = null
},
async showSettleReceivableDialog(userBalance) {
// Only show for users who owe castle (positive balance = receivable)
// Only show for users who owe libra (positive balance = receivable)
if (userBalance.balance <= 0) return
// Clear any existing polling
@ -1154,19 +1268,19 @@ window.app = Vue.createApp({
// Fetch unsettled entries for this user (BOTH receivables AND expenses for net settlement)
let allEntryLinks = []
try {
// Fetch receivable entries (user owes castle)
// Fetch receivable entries (user owes libra)
const receivableResponse = await LNbits.api.request(
'GET',
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
`/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
this.g.user.wallets[0].adminkey
)
const receivableEntries = receivableResponse.data.unsettled_entries || []
allEntryLinks.push(...receivableEntries.map(e => e.link).filter(l => l))
// Also fetch expense entries (castle owes user) - these are netted in the settlement
// Also fetch expense entries (libra owes user) - these are netted in the settlement
const expenseResponse = await LNbits.api.request(
'GET',
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
`/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
this.g.user.wallets[0].adminkey
)
const expenseEntries = expenseResponse.data.unsettled_entries || []
@ -1206,10 +1320,10 @@ window.app = Vue.createApp({
}
try {
// Generate an invoice on the Castle wallet for the user to pay
// Generate an invoice on the Libra wallet for the user to pay
const response = await LNbits.api.request(
'POST',
'/castle/api/v1/generate-payment-invoice',
'/libra/api/v1/generate-payment-invoice',
this.g.user.wallets[0].adminkey,
{
amount: this.settleReceivableDialog.amount,
@ -1336,7 +1450,7 @@ window.app = Vue.createApp({
const response = await LNbits.api.request(
'POST',
'/castle/api/v1/receivables/settle',
'/libra/api/v1/receivables/settle',
this.g.user.wallets[0].adminkey,
payload
)
@ -1360,7 +1474,7 @@ window.app = Vue.createApp({
}
},
async showPayUserDialog(userBalance) {
// Only show for users castle owes (negative balance = payable)
// Only show for users libra owes (negative balance = payable)
if (userBalance.balance >= 0) return
// Extract fiat balances (e.g., EUR)
@ -1368,26 +1482,26 @@ window.app = Vue.createApp({
const fiatCurrency = Object.keys(fiatBalances)[0] || null
const fiatAmount = fiatCurrency ? fiatBalances[fiatCurrency] : 0
// Use absolute values since balance is negative (liability = castle owes user)
// Use absolute values since balance is negative (liability = libra owes user)
const maxAmountSats = Math.abs(userBalance.balance)
const maxAmountFiat = Math.abs(fiatAmount)
// Fetch unsettled entries for this user (BOTH expenses AND receivables for net settlement)
let allEntryLinks = []
try {
// Fetch expense entries (castle owes user)
// Fetch expense entries (libra owes user)
const expenseResponse = await LNbits.api.request(
'GET',
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
`/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
this.g.user.wallets[0].adminkey
)
const expenseEntries = expenseResponse.data.unsettled_entries || []
allEntryLinks.push(...expenseEntries.map(e => e.link).filter(l => l))
// Also fetch receivable entries (user owes castle) - these are netted in the settlement
// Also fetch receivable entries (user owes libra) - these are netted in the settlement
const receivableResponse = await LNbits.api.request(
'GET',
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
`/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
this.g.user.wallets[0].adminkey
)
const receivableEntries = receivableResponse.data.unsettled_entries || []
@ -1400,10 +1514,10 @@ window.app = Vue.createApp({
show: true,
user_id: userBalance.user_id,
username: userBalance.username,
maxAmount: maxAmountSats, // Positive sats amount castle owes
maxAmount: maxAmountSats, // Positive sats amount libra owes
maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive)
fiatCurrency: fiatCurrency,
amount: fiatCurrency ? maxAmountFiat : maxAmountSats, // Default to fiat if available
amount: maxAmountSats, // Default to sats since lightning is the default payment method
payment_method: 'lightning', // Default to lightning for paying
description: '',
reference: '',
@ -1432,13 +1546,14 @@ window.app = Vue.createApp({
{
out: false,
amount: this.payUserDialog.amount,
memo: `Payment from Castle to ${this.payUserDialog.username}`
memo: `Payment from Libra to ${this.payUserDialog.username}`
}
)
console.log(invoiceResponse)
const paymentRequest = invoiceResponse.data.payment_request
const paymentRequest = invoiceResponse.data.bolt11
// Pay the invoice from Castle's wallet
// Pay the invoice from Libra's wallet
const paymentResponse = await LNbits.api.request(
'POST',
`/api/v1/payments`,
@ -1449,7 +1564,7 @@ window.app = Vue.createApp({
}
)
// Record the payment in Castle accounting
// Record the payment in Libra accounting
const payPayload = {
user_id: this.payUserDialog.user_id,
amount: this.payUserDialog.amount,
@ -1464,7 +1579,7 @@ window.app = Vue.createApp({
await LNbits.api.request(
'POST',
'/castle/api/v1/payables/pay',
'/libra/api/v1/payables/pay',
this.g.user.wallets[0].adminkey,
payPayload
)
@ -1530,7 +1645,7 @@ window.app = Vue.createApp({
const response = await LNbits.api.request(
'POST',
'/castle/api/v1/payables/pay',
'/libra/api/v1/payables/pay',
this.g.user.wallets[0].adminkey,
payload
)
@ -1557,7 +1672,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
`/castle/api/v1/user-wallet/${userId}`,
`/libra/api/v1/user-wallet/${userId}`,
this.g.user.wallets[0].adminkey
)
return response.data
@ -1593,6 +1708,34 @@ window.app = Vue.createApp({
formatSats(amount) {
return new Intl.NumberFormat().format(amount)
},
isIncomeEntry(entry) {
return Array.isArray(entry.tags) && entry.tags.includes('income-entry')
},
// Per-currency split for multi-currency balances. Sign convention from the
// super-user perspective: positive fiat = user owes Libra (Receivable),
// negative fiat = Libra owes user (Payable). Distinct currencies can't be
// netted across each other (no spot rate), so we render them grouped by
// direction instead of one collapsed label.
owesYouFiat(fiatBalances) {
if (!fiatBalances) return {}
return Object.fromEntries(
Object.entries(fiatBalances).filter(([_, amount]) => Number(amount) > 0.005)
)
},
youOweFiat(fiatBalances) {
if (!fiatBalances) return {}
return Object.fromEntries(
Object.entries(fiatBalances)
.filter(([_, amount]) => Number(amount) < -0.005)
.map(([cur, amount]) => [cur, Math.abs(Number(amount))])
)
},
hasOwesYouFiat(fiatBalances) {
return Object.keys(this.owesYouFiat(fiatBalances)).length > 0
},
hasYouOweFiat(fiatBalances) {
return Object.keys(this.youOweFiat(fiatBalances)).length > 0
},
formatFiat(amount, currency) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
@ -1614,13 +1757,13 @@ window.app = Vue.createApp({
return null
},
isReceivable(entry) {
// Check if this is a receivable entry (user owes castle)
// Check if this is a receivable entry (user owes libra)
if (entry.tags && entry.tags.includes('receivable-entry')) return true
if (entry.account && entry.account.includes('Receivable')) return true
return false
},
isPayable(entry) {
// Check if this is a payable entry (castle owes user)
// Check if this is a payable entry (libra owes user)
if (entry.tags && entry.tags.includes('expense-entry')) return true
if (entry.account && entry.account.includes('Payable')) return true
return false
@ -1630,6 +1773,10 @@ window.app = Vue.createApp({
if (entry.tags && entry.tags.includes('equity-contribution')) return true
if (entry.account && entry.account.includes('Equity')) return true
return false
},
isVoided(entry) {
// Voided entries keep '!' flag and carry a 'voided' tag (libra convention).
return Array.isArray(entry.tags) && entry.tags.includes('voided')
}
},
async created() {

View file

@ -53,6 +53,11 @@ window.app = Vue.createApp({
label: 'Submit Expense',
description: 'Submit expenses to this account'
},
{
value: 'submit_income',
label: 'Submit Income',
description: 'Submit income/revenue entries to this account'
},
{
value: 'manage',
label: 'Manage',
@ -206,7 +211,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/admin/permissions',
'/libra/api/v1/admin/permissions',
this.g.user.wallets[0].adminkey
)
this.permissions = response.data
@ -228,7 +233,7 @@ window.app = Vue.createApp({
// Admin permissions UI needs to see virtual accounts to grant permissions on them
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/accounts?exclude_virtual=false',
'/libra/api/v1/accounts?exclude_virtual=false',
this.g.user.wallets[0].inkey
)
this.accounts = response.data
@ -251,7 +256,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/admin/castle-users',
'/libra/api/v1/admin/libra-users',
this.g.user.wallets[0].adminkey
)
this.users = response.data || []
@ -318,7 +323,7 @@ window.app = Vue.createApp({
await LNbits.api.request(
'POST',
'/castle/api/v1/admin/permissions',
'/libra/api/v1/admin/permissions',
this.g.user.wallets[0].adminkey,
payload
)
@ -357,7 +362,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'DELETE',
`/castle/api/v1/admin/permissions/${this.permissionToRevoke.id}`,
`/libra/api/v1/admin/permissions/${this.permissionToRevoke.id}`,
this.g.user.wallets[0].adminkey
)
@ -428,7 +433,7 @@ window.app = Vue.createApp({
const response = await LNbits.api.request(
'POST',
'/castle/api/v1/admin/permissions/bulk-grant',
'/libra/api/v1/admin/permissions/bulk-grant',
this.g.user.wallets[0].adminkey,
payload
)
@ -501,6 +506,8 @@ window.app = Vue.createApp({
return 'blue'
case 'submit_expense':
return 'green'
case 'submit_income':
return 'teal'
case 'manage':
return 'red'
default:
@ -514,6 +521,8 @@ window.app = Vue.createApp({
return 'visibility'
case 'submit_expense':
return 'add_circle'
case 'submit_income':
return 'payments'
case 'manage':
return 'admin_panel_settings'
default:
@ -535,7 +544,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/admin/equity-eligibility',
'/libra/api/v1/admin/equity-eligibility',
this.g.user.wallets[0].adminkey
)
this.equityEligibleUsers = response.data || []
@ -573,7 +582,7 @@ window.app = Vue.createApp({
await LNbits.api.request(
'POST',
'/castle/api/v1/admin/equity-eligibility',
'/libra/api/v1/admin/equity-eligibility',
this.g.user.wallets[0].adminkey,
payload
)
@ -612,7 +621,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'DELETE',
`/castle/api/v1/admin/equity-eligibility/${this.equityToRevoke.user_id}`,
`/libra/api/v1/admin/equity-eligibility/${this.equityToRevoke.user_id}`,
this.g.user.wallets[0].adminkey
)
@ -655,7 +664,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/admin/roles',
'/libra/api/v1/admin/roles',
this.g.user.wallets[0].adminkey
)
this.roles = response.data || []
@ -678,7 +687,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
`/castle/api/v1/admin/roles/${role.id}`,
`/libra/api/v1/admin/roles/${role.id}`,
this.g.user.wallets[0].adminkey
)
@ -700,7 +709,7 @@ window.app = Vue.createApp({
}
},
editRole(role) {
async editRole(role) {
this.editingRole = true
this.selectedRole = role
this.roleForm = {
@ -708,6 +717,28 @@ window.app = Vue.createApp({
description: role.description || '',
is_default: role.is_default || false
}
this.rolePermissionsForView = []
this.roleUsersForView = []
try {
const response = await LNbits.api.request(
'GET',
`/libra/api/v1/admin/roles/${role.id}`,
this.g.user.wallets[0].adminkey
)
this.rolePermissionsForView = [...(response.data.permissions || [])]
this.roleUsersForView = [...(response.data.users || [])]
} catch (error) {
console.error('Failed to load role details:', error)
this.$q.notify({
type: 'negative',
message: 'Failed to load role permissions',
caption: error.message || 'Unknown error',
timeout: 5000
})
}
await this.$nextTick()
this.showCreateRoleDialog = true
},
@ -733,7 +764,7 @@ window.app = Vue.createApp({
// Update existing role
await LNbits.api.request(
'PUT',
`/castle/api/v1/admin/roles/${this.selectedRole.id}`,
`/libra/api/v1/admin/roles/${this.selectedRole.id}`,
this.g.user.wallets[0].adminkey,
payload
)
@ -747,7 +778,7 @@ window.app = Vue.createApp({
// Create new role
await LNbits.api.request(
'POST',
'/castle/api/v1/admin/roles',
'/libra/api/v1/admin/roles',
this.g.user.wallets[0].adminkey,
payload
)
@ -786,7 +817,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'DELETE',
`/castle/api/v1/admin/roles/${this.roleToDelete.id}`,
`/libra/api/v1/admin/roles/${this.roleToDelete.id}`,
this.g.user.wallets[0].adminkey
)
@ -815,6 +846,8 @@ window.app = Vue.createApp({
this.showCreateRoleDialog = false
this.editingRole = false
this.selectedRole = null
this.roleUsersForView = []
this.rolePermissionsForView = []
this.resetRoleForm()
},
@ -862,7 +895,7 @@ window.app = Vue.createApp({
await LNbits.api.request(
'POST',
'/castle/api/v1/admin/user-roles',
'/libra/api/v1/admin/user-roles',
this.g.user.wallets[0].adminkey,
payload
)
@ -920,7 +953,7 @@ window.app = Vue.createApp({
try {
const response = await LNbits.api.request(
'GET',
'/castle/api/v1/admin/users/roles',
'/libra/api/v1/admin/users/roles',
this.g.user.wallets[0].adminkey
)
@ -984,7 +1017,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'DELETE',
`/castle/api/v1/admin/user-roles/${this.userRoleToRevoke.id}`,
`/libra/api/v1/admin/user-roles/${this.userRoleToRevoke.id}`,
this.g.user.wallets[0].adminkey
)
@ -1033,7 +1066,7 @@ window.app = Vue.createApp({
}
await LNbits.api.request(
'POST',
`/castle/api/v1/admin/roles/${this.selectedRole.id}/permissions`,
`/libra/api/v1/admin/roles/${this.selectedRole.id}/permissions`,
this.g.user.wallets[0].adminkey,
payload
)
@ -1067,7 +1100,7 @@ window.app = Vue.createApp({
try {
await LNbits.api.request(
'DELETE',
`/castle/api/v1/admin/roles/${this.selectedRole.id}/permissions/${permissionId}`,
`/libra/api/v1/admin/roles/${this.selectedRole.id}/permissions/${permissionId}`,
this.g.user.wallets[0].adminkey
)
// Reload role permissions
@ -1118,5 +1151,3 @@ window.app = Vue.createApp({
}
}
})
window.app.mount('#vue')

293
tasks.py
View file

@ -1,5 +1,5 @@
"""
Background tasks for Castle accounting extension.
Background tasks for Libra accounting extension.
These tasks handle automated reconciliation checks and maintenance.
"""
@ -62,11 +62,11 @@ async def check_all_balance_assertions() -> dict:
# Log results
if results["failed"] > 0:
print(f"[CASTLE] Daily reconciliation check: {results['failed']} FAILED assertions!")
print(f"[LIBRA] Daily reconciliation check: {results['failed']} FAILED assertions!")
for failed in results["failed_assertions"]:
print(f" - Account {failed['account_id']}: expected {failed['expected_sats']}, got {failed['actual_sats']}")
else:
print(f"[CASTLE] Daily reconciliation check: All {results['passed']} assertions passed ✓")
print(f"[LIBRA] Daily reconciliation check: All {results['passed']} assertions passed ✓")
return results
@ -78,7 +78,7 @@ async def scheduled_daily_reconciliation():
This function is meant to be called by a scheduler (cron, systemd timer, etc.)
or by LNbits background task system.
"""
print(f"[CASTLE] Running scheduled daily reconciliation check at {datetime.now()}")
print(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}")
try:
results = await check_all_balance_assertions()
@ -86,38 +86,38 @@ async def scheduled_daily_reconciliation():
# TODO: Send notifications if there are failures
# This could send email, webhook, or in-app notification
if results["failed"] > 0:
print(f"[CASTLE] WARNING: {results['failed']} balance assertions failed!")
print(f"[LIBRA] WARNING: {results['failed']} balance assertions failed!")
# Future: Send alert notification
return results
except Exception as e:
print(f"[CASTLE] Error in scheduled reconciliation: {e}")
print(f"[LIBRA] Error in scheduled reconciliation: {e}")
raise
async def scheduled_account_sync():
"""
Scheduled task that runs hourly to sync accounts from Beancount to Castle DB.
Scheduled task that runs hourly to sync accounts from Beancount to Libra DB.
This ensures Castle DB stays in sync with Beancount (source of truth) by
automatically adding any new accounts created in Beancount to Castle's
This ensures Libra DB stays in sync with Beancount (source of truth) by
automatically adding any new accounts created in Beancount to Libra's
metadata database for permission tracking.
"""
from .account_sync import sync_accounts_from_beancount
logger.info(f"[CASTLE] Running scheduled account sync at {datetime.now()}")
logger.info(f"[LIBRA] Running scheduled account sync at {datetime.now()}")
try:
stats = await sync_accounts_from_beancount(force_full_sync=False)
if stats["accounts_added"] > 0:
logger.info(
f"[CASTLE] Account sync: Added {stats['accounts_added']} new accounts"
f"[LIBRA] Account sync: Added {stats['accounts_added']} new accounts"
)
if stats["errors"]:
logger.warning(
f"[CASTLE] Account sync: {len(stats['errors'])} errors encountered"
f"[LIBRA] Account sync: {len(stats['errors'])} errors encountered"
)
for error in stats["errors"][:5]: # Log first 5 errors
logger.error(f" - {error}")
@ -125,24 +125,31 @@ async def scheduled_account_sync():
return stats
except Exception as e:
logger.error(f"[CASTLE] Error in scheduled account sync: {e}")
logger.error(f"[LIBRA] Error in scheduled account sync: {e}")
raise
async def wait_for_account_sync():
"""
Background task that periodically syncs accounts from Beancount to Castle DB.
Background task that periodically syncs accounts from Beancount to Libra DB.
Runs hourly to ensure Castle DB stays in sync with Beancount.
Runs hourly to ensure Libra DB stays in sync with Beancount.
Blocks on `wait_for_fava_client()` before the first iteration so we don't
race the fire-and-forget `_init_fava()` started in `libra_start()` and
fail the first sync with "Fava client not initialized".
"""
logger.info("[CASTLE] Account sync background task started")
from .fava_client import wait_for_fava_client
logger.info("[LIBRA] Account sync background task started")
await wait_for_fava_client()
while True:
try:
# Run sync
await scheduled_account_sync()
except Exception as e:
logger.error(f"[CASTLE] Account sync error: {e}")
logger.error(f"[LIBRA] Account sync error: {e}")
# Wait 1 hour before next sync
await asyncio.sleep(3600) # 3600 seconds = 1 hour
@ -157,9 +164,9 @@ def start_daily_reconciliation_task():
For cron setup:
# Run daily at 2 AM
0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
"""
print("[CASTLE] Daily reconciliation task registered")
print("[LIBRA] Daily reconciliation task registered")
# In a production system, you would register this with LNbits task scheduler
# For now, it can be triggered manually via API endpoint
@ -173,7 +180,7 @@ async def wait_for_paid_invoices():
before the payment is detected by client-side polling.
"""
invoice_queue = Queue()
register_invoice_listener(invoice_queue, "ext_castle")
register_invoice_listener(invoice_queue, "ext_libra")
while True:
payment = await invoice_queue.get()
@ -182,149 +189,141 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None:
"""
Handle a paid Castle invoice by automatically submitting to Fava.
Handle a paid Libra invoice by automatically submitting to Fava.
This function is called automatically when any invoice on the Castle wallet
is paid. It checks if the invoice is a Castle payment and records it in
This function is called automatically when any invoice on the Libra wallet
is paid. It checks if the invoice is a Libra payment and records it in
Beancount via Fava.
Concurrency Protection:
- Uses per-user locking to prevent race conditions when multiple payments
for the same user are processed simultaneously
- Uses idempotent entry creation to prevent duplicate entries even if
the same payment is processed multiple times
"""
# Only process Castle-specific payments
if not payment.extra or payment.extra.get("tag") != "castle":
# Only process Libra-specific payments
if not payment.extra or payment.extra.get("tag") != "libra":
return
user_id = payment.extra.get("user_id")
if not user_id:
logger.warning(f"Castle invoice {payment.payment_hash} missing user_id in metadata")
logger.warning(f"Libra invoice {payment.payment_hash} missing user_id in metadata")
return
# Check if payment already recorded (idempotency)
# Query Fava for existing entry with this payment hash link
from .fava_client import get_fava_client
import httpx
fava = get_fava_client()
try:
# Check if payment already recorded by fetching recent entries
# Note: We can't use BQL query with `links ~ 'pattern'` because links is a set type
# and BQL doesn't support regex matching on sets. Instead, fetch entries and filter in Python.
link_to_find = f"ln-{payment.payment_hash[:16]}"
# Use idempotency key based on payment hash - this ensures duplicate
# processing of the same payment won't create duplicate entries
idempotency_key = f"ln-{payment.payment_hash[:16]}"
async with httpx.AsyncClient(timeout=5.0) as client:
# Get recent entries from Fava's journal endpoint
response = await client.get(
f"{fava.base_url}/api/journal",
params={"time": ""} # Get all entries
# Acquire per-user lock to serialize processing for this user
# This prevents race conditions when a user has multiple payments being processed
user_lock = fava.get_user_lock(user_id)
async with user_lock:
logger.info(f"Recording Libra payment {payment.payment_hash} for user {user_id[:8]} to Fava")
try:
from decimal import Decimal
from .crud import get_account_by_name, get_or_create_user_account
from .models import AccountType
from .beancount_format import format_net_settlement_entry
# Convert amount from millisatoshis to satoshis
amount_sats = payment.amount // 1000
# Extract fiat metadata from invoice (if present)
fiat_currency = None
fiat_amount = None
if payment.extra:
fiat_currency = payment.extra.get("fiat_currency")
fiat_amount_str = payment.extra.get("fiat_amount")
if fiat_amount_str:
fiat_amount = Decimal(str(fiat_amount_str))
if not fiat_currency or not fiat_amount:
logger.error(f"Payment {payment.payment_hash} missing fiat currency/amount metadata")
return
# Get user's current balance to determine receivables and payables
balance = await fava.get_user_balance(user_id)
fiat_balances = balance.get("fiat_balances", {})
total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0))
# Determine receivables and payables based on balance
# Positive balance = user owes libra (receivable)
# Negative balance = libra owes user (payable)
if total_fiat_balance > 0:
# User owes libra
total_receivable = total_fiat_balance
total_payable = Decimal(0)
else:
# Libra owes user
total_receivable = Decimal(0)
total_payable = abs(total_fiat_balance)
logger.info(f"Settlement: {fiat_amount} {fiat_currency} (Receivable: {total_receivable}, Payable: {total_payable})")
# Get account names
user_receivable = await get_or_create_user_account(
user_id, AccountType.ASSET, "Accounts Receivable"
)
user_payable = await get_or_create_user_account(
user_id, AccountType.LIABILITY, "Accounts Payable"
)
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
return
# Query for unsettled entries to link this settlement back to them
# Net settlement can settle both expenses and receivables
settled_links = []
try:
unsettled_expenses = await fava.get_unsettled_entries_bql(user_id, "expense")
settled_links.extend([e["link"] for e in unsettled_expenses if e.get("link")])
unsettled_receivables = await fava.get_unsettled_entries_bql(user_id, "receivable")
settled_links.extend([e["link"] for e in unsettled_receivables if e.get("link")])
except Exception as e:
logger.warning(f"Could not query unsettled entries for settlement links: {e}")
# Continue without links - settlement will still be recorded
# Format as net settlement transaction
entry = format_net_settlement_entry(
user_id=user_id,
payment_account=lightning_account.name,
receivable_account=user_receivable.name,
payable_account=user_payable.name,
amount_sats=amount_sats,
net_fiat_amount=fiat_amount,
total_receivable_fiat=total_receivable,
total_payable_fiat=total_payable,
fiat_currency=fiat_currency,
description=f"Lightning payment settlement from user {user_id[:8]}",
entry_date=datetime.now().date(),
payment_hash=payment.payment_hash,
reference=payment.payment_hash,
settled_entry_links=settled_links if settled_links else None
)
if response.status_code == 200:
data = response.json()
entries = data.get('entries', [])
# Submit to Fava using idempotent method to prevent duplicates
# The idempotency key is based on the payment hash, so even if this
# function is called multiple times for the same payment, only one
# entry will be created
result = await fava.add_entry_idempotent(entry, idempotency_key)
# Check if any entry has our payment link
for entry in entries:
entry_links = entry.get('links', [])
if link_to_find in entry_links:
logger.info(f"Payment {payment.payment_hash} already recorded in Fava, skipping")
return
if result.get("existing"):
logger.info(
f"Payment {payment.payment_hash} was already recorded in Fava (idempotent)"
)
else:
logger.info(
f"Successfully recorded payment {payment.payment_hash} to Fava: "
f"{result.get('data', 'Unknown')}"
)
except Exception as e:
logger.warning(f"Could not check Fava for duplicate payment: {e}")
# Continue anyway - Fava/Beancount will catch duplicate if it exists
logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]} to Fava")
try:
from decimal import Decimal
from .crud import get_account_by_name, get_or_create_user_account
from .models import AccountType
from .beancount_format import format_net_settlement_entry
# Convert amount from millisatoshis to satoshis
amount_sats = payment.amount // 1000
# Extract fiat metadata from invoice (if present)
fiat_currency = None
fiat_amount = None
if payment.extra:
fiat_currency = payment.extra.get("fiat_currency")
fiat_amount_str = payment.extra.get("fiat_amount")
if fiat_amount_str:
fiat_amount = Decimal(str(fiat_amount_str))
if not fiat_currency or not fiat_amount:
logger.error(f"Payment {payment.payment_hash} missing fiat currency/amount metadata")
return
# Get user's current balance to determine receivables and payables
balance = await fava.get_user_balance(user_id)
fiat_balances = balance.get("fiat_balances", {})
total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0))
# Determine receivables and payables based on balance
# Positive balance = user owes castle (receivable)
# Negative balance = castle owes user (payable)
if total_fiat_balance > 0:
# User owes castle
total_receivable = total_fiat_balance
total_payable = Decimal(0)
else:
# Castle owes user
total_receivable = Decimal(0)
total_payable = abs(total_fiat_balance)
logger.info(f"Settlement: {fiat_amount} {fiat_currency} (Receivable: {total_receivable}, Payable: {total_payable})")
# Get account names
user_receivable = await get_or_create_user_account(
user_id, AccountType.ASSET, "Accounts Receivable"
)
user_payable = await get_or_create_user_account(
user_id, AccountType.LIABILITY, "Accounts Payable"
)
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
return
# Query for unsettled entries to link this settlement back to them
# Net settlement can settle both expenses and receivables
settled_links = []
try:
unsettled_expenses = await fava.get_unsettled_entries_bql(user_id, "expense")
settled_links.extend([e["link"] for e in unsettled_expenses if e.get("link")])
unsettled_receivables = await fava.get_unsettled_entries_bql(user_id, "receivable")
settled_links.extend([e["link"] for e in unsettled_receivables if e.get("link")])
except Exception as e:
logger.warning(f"Could not query unsettled entries for settlement links: {e}")
# Continue without links - settlement will still be recorded
# Format as net settlement transaction
entry = format_net_settlement_entry(
user_id=user_id,
payment_account=lightning_account.name,
receivable_account=user_receivable.name,
payable_account=user_payable.name,
amount_sats=amount_sats,
net_fiat_amount=fiat_amount,
total_receivable_fiat=total_receivable,
total_payable_fiat=total_payable,
fiat_currency=fiat_currency,
description=f"Lightning payment settlement from user {user_id[:8]}",
entry_date=datetime.now().date(),
payment_hash=payment.payment_hash,
reference=payment.payment_hash,
settled_entry_links=settled_links if settled_links else None
)
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(
f"Successfully recorded payment {payment.payment_hash} to Fava: "
f"{result.get('data', 'Unknown')}"
)
except Exception as e:
logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}")
raise
logger.error(f"Error recording Libra payment {payment.payment_hash}: {e}")
raise

View file

@ -3,7 +3,7 @@
{% block scripts %}
{{ window_vars(user) }}
<script src="{{ static_url_for('castle/static', path='js/index.js') }}"></script>
<script src="{{ static_url_for('libra/static', path='js/index.js') }}"></script>
{% endblock %}
{% block page %}
@ -13,18 +13,22 @@
<q-card-section>
<div class="row items-center no-wrap">
<div class="col">
<h5 class="q-my-none">🏰 Castle Accounting</h5>
<h5 class="q-my-none">Libra</h5>
<p class="q-mb-none">Track expenses, receivables, and balances for the collective</p>
</div>
<div class="col-auto q-gutter-xs">
<q-btn v-if="!isSuperUser" flat round icon="account_balance_wallet" @click="showUserWalletDialog">
<!-- Wait for settings to load before showing role-specific buttons to prevent race conditions -->
<q-btn v-if="settingsLoaded && !isSuperUser" flat round icon="account_balance_wallet" @click="showUserWalletDialog">
<q-tooltip>Configure Your Wallet</q-tooltip>
</q-btn>
<q-btn v-if="isSuperUser" flat round icon="admin_panel_settings" :href="'/castle/permissions'">
<q-btn v-if="settingsLoaded && isSuperUser" flat round icon="admin_panel_settings" :href="'/libra/permissions'">
<q-tooltip>Manage Permissions (Admin)</q-tooltip>
</q-btn>
<q-btn v-if="isSuperUser" flat round icon="settings" @click="showSettingsDialog">
<q-tooltip>Castle Settings (Super User Only)</q-tooltip>
<q-btn v-if="settingsLoaded && isSuperUser" flat round icon="sync" :loading="syncingAccounts" @click="syncAccounts">
<q-tooltip>Sync Accounts from Beancount</q-tooltip>
</q-btn>
<q-btn v-if="settingsLoaded && isSuperUser" flat round icon="settings" @click="showSettingsDialog">
<q-tooltip>Libra Settings (Super User Only)</q-tooltip>
</q-btn>
</div>
</div>
@ -32,19 +36,19 @@
</q-card>
<!-- Setup Warning -->
<q-banner v-if="!castleWalletConfigured && isSuperUser" class="bg-warning text-white" rounded>
<q-banner v-if="settingsLoaded && !libraWalletConfigured && isSuperUser" class="bg-warning text-white" rounded>
<template v-slot:avatar>
<q-icon name="warning" color="white"></q-icon>
</template>
<div>
<strong>Setup Required:</strong> Castle Wallet ID must be configured before the extension can function.
<strong>Setup Required:</strong> Libra Wallet ID must be configured before the extension can function.
</div>
<template v-slot:action>
<q-btn flat color="white" label="Configure Now" @click="showSettingsDialog"></q-btn>
</template>
</q-banner>
<q-banner v-if="!castleWalletConfigured && !isSuperUser" class="bg-info text-white" rounded>
<q-banner v-if="settingsLoaded && !libraWalletConfigured && !isSuperUser" class="bg-info text-white" rounded>
<template v-slot:avatar>
<q-icon name="info" color="white"></q-icon>
</template>
@ -53,7 +57,7 @@
</div>
</q-banner>
<q-banner v-if="castleWalletConfigured && !userWalletConfigured && !isSuperUser" class="bg-orange text-white" rounded>
<q-banner v-if="settingsLoaded && libraWalletConfigured && !userWalletConfigured && !isSuperUser" class="bg-orange text-white" rounded>
<template v-slot:avatar>
<q-icon name="account_balance_wallet" color="white"></q-icon>
</template>
@ -65,18 +69,19 @@
</template>
</q-banner>
<!-- Pending Expense Entries (Super User Only) -->
<!-- Pending Entries (Super User Only) -->
<q-card v-if="isSuperUser && pendingExpenses.length > 0">
<q-card-section>
<h6 class="q-my-none q-mb-md">Pending Expense Approvals</h6>
<h6 class="q-my-none q-mb-md">Pending Approvals</h6>
<q-list separator>
<q-item v-for="entry in pendingExpenses" :key="entry.id">
<q-item-section avatar>
<q-icon name="pending" color="orange" size="sm">
<q-tooltip>Pending approval</q-tooltip>
</q-icon>
</q-item-section>
<q-item-section>
<q-item-label caption>
<q-badge
:color="isIncomeEntry(entry) ? 'green' : 'red'"
:label="isIncomeEntry(entry) ? 'INCOME' : 'EXPENSE'"
/>
</q-item-label>
<q-item-label>{% raw %}{{ entry.description }}{% endraw %}</q-item-label>
<q-item-label caption>
{% raw %}{{ formatDate(entry.entry_date) }}{% endraw %}
@ -127,13 +132,13 @@
<q-btn
color="primary"
@click="expenseDialog.show = true"
:disable="!castleWalletConfigured || (!userWalletConfigured && !isSuperUser)"
:disable="!libraWalletConfigured || (!userWalletConfigured && !isSuperUser)"
>
Add Expense
<q-tooltip v-if="!castleWalletConfigured">
Castle wallet must be configured first
<q-tooltip v-if="!libraWalletConfigured">
Libra wallet must be configured first
</q-tooltip>
<q-tooltip v-if="castleWalletConfigured && !userWalletConfigured && !isSuperUser">
<q-tooltip v-if="libraWalletConfigured && !userWalletConfigured && !isSuperUser">
You must configure your wallet first
</q-tooltip>
</q-btn>
@ -141,14 +146,14 @@
v-if="isSuperUser"
color="orange"
@click="showReceivableDialog"
:disable="!castleWalletConfigured"
:disable="!libraWalletConfigured"
>
Add Receivable
<q-tooltip v-if="!castleWalletConfigured">
Castle wallet must be configured first
<q-tooltip v-if="!libraWalletConfigured">
Libra wallet must be configured first
</q-tooltip>
<q-tooltip v-else>
Record when a user owes the Castle
Record when a user owes the organization
</q-tooltip>
</q-btn>
<q-btn color="secondary" @click="loadTransactions">
@ -182,22 +187,33 @@
</template>
<template v-slot:body-cell-balance="props">
<q-td :props="props">
<div :class="props.row.balance > 0 ? 'text-positive' : 'text-negative'">
{% raw %}{{ formatSats(Math.abs(props.row.balance)) }} sats{% endraw %}
<!-- User owes you (org), per currency -->
<div v-if="hasOwesYouFiat(props.row.fiat_balances)" class="text-positive">
<div v-for="(amount, currency) in owesYouFiat(props.row.fiat_balances)" :key="'oy-' + currency">
Owes you {% raw %}{{ formatFiat(amount, currency) }}{% endraw %}
</div>
</div>
<div v-if="props.row.fiat_balances && Object.keys(props.row.fiat_balances).length > 0" class="text-caption">
<span v-for="(amount, currency) in props.row.fiat_balances" :key="currency" class="q-mr-sm">
{% raw %}{{ formatFiat(Math.abs(amount), currency) }}{% endraw %}
</span>
<!-- You (org) owe user, per currency -->
<div v-if="hasYouOweFiat(props.row.fiat_balances)" class="text-negative">
<div v-for="(amount, currency) in youOweFiat(props.row.fiat_balances)" :key="'yo-' + currency">
You owe {% raw %}{{ formatFiat(amount, currency) }}{% endraw %}
</div>
</div>
<div class="text-caption text-grey">
{% raw %}{{ props.row.balance > 0 ? 'Owes you' : 'You owe' }}{% endraw %}
<!-- Fallback when there are no fiat balances (sats-only entries) -->
<div v-if="!hasOwesYouFiat(props.row.fiat_balances) && !hasYouOweFiat(props.row.fiat_balances)"
:class="props.row.balance > 0 ? 'text-positive' : 'text-negative'">
{% raw %}{{ props.row.balance > 0 ? 'Owes you' : 'You owe' }} {{ formatSats(Math.abs(props.row.balance)) }} sats{% endraw %}
</div>
<!-- Net sats footnote (current-rate-derived; can't be netted across currencies) -->
<div v-if="hasOwesYouFiat(props.row.fiat_balances) || hasYouOweFiat(props.row.fiat_balances)"
class="text-caption text-grey q-mt-xs">
Net (current rates): {% raw %}{{ formatSats(Math.abs(props.row.balance)) }} sats {{ props.row.balance > 0 ? '(receivable)' : '(payable)' }}{% endraw %}
</div>
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<!-- User owes Castle (positive balance) - Castle receives payment -->
<!-- User owes Libra (positive balance) - Libra receives payment -->
<q-btn
v-if="props.row.balance > 0"
flat
@ -207,9 +223,9 @@
icon="payments"
@click="showSettleReceivableDialog(props.row)"
>
<q-tooltip>Settle receivable (user pays castle)</q-tooltip>
<q-tooltip>Settle receivable (user pays libra)</q-tooltip>
</q-btn>
<!-- Castle owes User (negative balance) - Castle pays user -->
<!-- Libra owes User (negative balance) - Libra pays user -->
<q-btn
v-if="props.row.balance < 0"
flat
@ -219,7 +235,7 @@
icon="send"
@click="showPayUserDialog(props.row)"
>
<q-tooltip>Pay user (castle pays user)</q-tooltip>
<q-tooltip>Pay user (libra pays user)</q-tooltip>
</q-btn>
</q-td>
</template>
@ -253,7 +269,7 @@
{% raw %}{{ balance.balance > 0 ? 'Total owed to you' : balance.balance < 0 ? 'Total you owe' : 'No outstanding balances' }}{% endraw %}
</div>
<div class="text-subtitle2" v-else>
{% raw %}{{ balance.balance >= 0 ? 'You owe Castle' : 'Castle owes you' }}{% endraw %}
{% raw %}{{ balance.balance >= 0 ? 'You owe Libra' : 'Libra owes you' }}{% endraw %}
</div>
<div class="q-mt-md q-gutter-sm">
<q-btn
@ -481,7 +497,10 @@
<!-- Status Flag Column -->
<template v-slot:body-cell-flag="props">
<q-td :props="props">
<q-icon v-if="props.row.flag === '*'" name="check_circle" color="positive" size="sm">
<q-icon v-if="isVoided(props.row)" name="cancel" color="grey" size="sm">
<q-tooltip>Voided</q-tooltip>
</q-icon>
<q-icon v-else-if="props.row.flag === '*'" name="check_circle" color="positive" size="sm">
<q-tooltip>Cleared</q-tooltip>
</q-icon>
<q-icon v-else-if="props.row.flag === '!'" name="pending" color="orange" size="sm">
@ -490,9 +509,6 @@
<q-icon v-else-if="props.row.flag === '#'" name="flag" color="red" size="sm">
<q-tooltip>Flagged</q-tooltip>
</q-icon>
<q-icon v-else-if="props.row.flag === 'x'" name="cancel" color="grey" size="sm">
<q-tooltip>Voided</q-tooltip>
</q-icon>
</q-td>
</template>
@ -841,7 +857,20 @@
<!-- Chart of Accounts -->
<q-card>
<q-card-section>
<h6 class="q-my-none q-mb-md">Chart of Accounts</h6>
<div class="row items-center q-mb-md">
<h6 class="q-my-none">Chart of Accounts</h6>
<q-space></q-space>
<q-btn
v-if="isSuperUser"
unelevated
dense
size="sm"
color="primary"
icon="add"
label="Add Account"
@click="showAddAccountDialog"
></q-btn>
</div>
<q-list dense v-if="accounts.length > 0">
<q-item v-for="account in accounts" :key="account.id">
<q-item-section>
@ -920,7 +949,7 @@
dense
v-model="expenseDialog.isEquity"
:options="[
{label: 'Liability (Castle owes me)', value: false},
{label: 'Liability (Libra owes me)', value: false},
{label: 'Equity (My contribution)', value: true}
]"
option-label="label"
@ -928,7 +957,7 @@
emit-value
map-options
label="Type *"
hint="Choose whether this is a liability (Castle owes you) or an equity contribution"
hint="Choose whether this is a liability (Libra owes you) or an equity contribution"
></q-select>
<!-- If user is not equity eligible, force liability -->
@ -937,9 +966,9 @@
filled
dense
readonly
:model-value="'Liability (Castle owes me)'"
:model-value="'Liability (Libra owes me)'"
label="Type"
hint="This expense will be recorded as a liability (Castle owes you)"
hint="This expense will be recorded as a liability (Libra owes you)"
>
<template v-slot:prepend>
<q-icon name="info" color="blue-grey-7"></q-icon>
@ -1052,7 +1081,7 @@
<div class="text-h6 q-mb-md">Request Manual Payment</div>
<div class="text-caption text-grey q-mb-md">
Request the Castle to pay you manually (cash, bank transfer, etc.) to settle your balance.
Request a manual payment (cash, bank transfer, etc.) from an admin to settle your balance.
</div>
<div v-if="balance" class="q-mb-md">
@ -1100,7 +1129,7 @@
<q-dialog v-model="settingsDialog.show" position="top">
<q-card v-if="settingsDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="submitSettings" class="q-gutter-md">
<div class="text-h6 q-mb-md">Castle Settings</div>
<div class="text-h6 q-mb-md">Libra Settings</div>
<q-banner v-if="!isSuperUser" class="bg-warning text-dark q-mb-md" dense rounded>
<template v-slot:avatar>
@ -1115,17 +1144,53 @@
filled
dense
emit-value
v-model="settingsDialog.castleWalletId"
v-model="settingsDialog.libraWalletId"
:options="g.user.walletOptions"
label="Castle Wallet *"
label="Libra Wallet *"
:readonly="!isSuperUser"
:disable="!isSuperUser"
></q-select>
<div class="text-caption text-grey">
Select the wallet that will be used for Castle operations and transactions.
<div class="text-caption text-grey q-mb-md">
Select the wallet that will be used for Libra operations and transactions.
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle2 q-mb-sm">Fava/Beancount Integration</div>
<q-input
filled
dense
v-model="settingsDialog.favaUrl"
label="Fava URL *"
hint="Base URL of the Fava server (e.g., http://localhost:3333)"
:readonly="!isSuperUser"
:disable="!isSuperUser"
></q-input>
<q-input
filled
dense
v-model="settingsDialog.favaLedgerSlug"
label="Ledger Slug"
hint="Ledger identifier in Fava URL (e.g., libra-ledger)"
:readonly="!isSuperUser"
:disable="!isSuperUser"
></q-input>
<q-input
filled
dense
type="number"
step="0.5"
v-model.number="settingsDialog.favaTimeout"
label="Timeout (seconds)"
hint="Request timeout for Fava API calls"
:readonly="!isSuperUser"
:disable="!isSuperUser"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="isSuperUser"
@ -1133,7 +1198,7 @@
color="primary"
type="submit"
:loading="settingsDialog.loading"
:disable="!settingsDialog.castleWalletId"
:disable="!settingsDialog.libraWalletId"
>
Save Settings
</q-btn>
@ -1161,7 +1226,7 @@
></q-select>
<div class="text-caption text-grey">
Select the wallet you'll use for Castle transactions.
Select the wallet you'll use for Libra transactions.
</div>
<div class="row q-mt-lg">
@ -1180,6 +1245,63 @@
</q-card>
</q-dialog>
<!-- Add Account Dialog -->
<q-dialog v-model="addAccountDialog.show" position="top">
<q-card v-if="addAccountDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="submitAddAccount" class="q-gutter-md">
<div class="text-h6 q-mb-md">Add Account</div>
<q-select
filled
dense
v-model="addAccountDialog.rootType"
:options="accountRootTypes"
label="Account Type *"
hint="Top-level category — the only valid parents"
></q-select>
<q-input
filled
dense
v-model.trim="addAccountDialog.subPath"
label="Sub-account *"
placeholder="e.g., Vehicle:Gas"
hint="Path under the type. Use ':' to nest; capitalize each part."
></q-input>
<div v-if="addAccountFullName" class="text-caption text-grey">
Will create: <span class="text-weight-medium">{% raw %}{{ addAccountFullName }}{% endraw %}</span>
</div>
<q-input
filled
dense
v-model.trim="addAccountDialog.description"
label="Description"
placeholder="Optional notes about this account"
></q-input>
<div class="text-caption text-grey">
Creates an Open directive in the Beancount ledger and syncs it into Libra
so permissions can be granted. Per-user accounts are managed automatically.
</div>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
type="submit"
:loading="addAccountDialog.loading"
:disable="!addAccountFullName"
>
Create Account
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<!-- Receivable Dialog -->
<q-dialog v-model="receivableDialog.show" position="top">
<q-card v-if="receivableDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
@ -1267,7 +1389,7 @@
<div class="text-caption text-grey q-mb-md">
Balance assertions are written to your Beancount ledger and validated automatically by Beancount.
This verifies that an account's actual balance matches your expected balance at a specific date.
If the assertion fails, Beancount will alert you to investigate the discrepancy. Castle stores
If the assertion fails, Beancount will alert you to investigate the discrepancy. Libra stores
metadata (tolerance, notes) for your convenience.
</div>
@ -1491,7 +1613,7 @@
</q-card>
</q-dialog>
<!-- Pay User Dialog (Castle pays user - Super User Only) -->
<!-- Pay User Dialog (Libra pays user - Super User Only) -->
<q-dialog v-model="payUserDialog.show" position="top">
<q-card class="q-pa-md" style="min-width: 400px">
<q-form @submit="submitPayUser">
@ -1504,7 +1626,7 @@
</div>
<div class="q-mb-md">
<div class="text-subtitle2">Amount Castle Owes</div>
<div class="text-subtitle2">Amount Libra Owes</div>
<div class="text-positive text-h6">
{% raw %}{{ formatSats(payUserDialog.maxAmount) }}{% endraw %} sats
</div>
@ -1519,7 +1641,7 @@
v-model.number="payUserDialog.amount"
type="number"
:label="paymentAmountLabel"
hint="Amount castle is paying (max: owed amount)"
hint="Amount libra is paying (max: owed amount)"
:max="paymentMaxAmount"
:step="paymentAmountStep"
:rules="[

View file

@ -4,7 +4,7 @@
{% block scripts %}
{{ window_vars(user) }}
<script src="{{ static_url_for('castle/static', path='js/permissions.js') }}"></script>
<script src="{{ static_url_for('libra/static', path='js/permissions.js') }}"></script>
{% endblock %}
{% block page %}

82
tests/README.md Normal file
View file

@ -0,0 +1,82 @@
# Libra extension tests
Integration tests covering the user- and admin-facing flows of the libra extension. Tests run against a real `fava` subprocess and a full LNbits app so they catch behaviour that mocks would miss (BQL semantics, Beancount arithmetic, multi-currency aggregation, HTTP boundary).
## Layout
- `conftest.py` — session-scoped Fava subprocess + LNbits app + user/wallet fixtures.
- `helpers.py` — high-level wrappers for the common API flows (`post_expense`, `settle_receivable`, `approve_manual_payment_request`, …). One per intention, so test bodies read as sequences of actions rather than HTTP calls.
- `test_smoke.py` — single end-to-end test; run first to validate the harness.
- `test_<area>_api.py` — per-flow coverage (entries, balances, settlement, manual payment requests, lightning, reconciliation, settings/auth, void/reject).
- `test_unit.py` — pure functions (`beancount_format`, `account_utils`, `core/validation`); no harness.
## Prerequisites
The harness requires `fava` on PATH. On NixOS:
```bash
nix-shell -p python3Packages.fava
```
Inside the regtest container `fava` is already provisioned.
## Running
The suite targets the **`lnbits/dev` worktree** (`~/dev/lnbits/dev`) — it
relies on dev-branch modules (`lnbits.core.signers`, the bunker work) that
`main` doesn't carry. A known-good invocation from scratch:
```bash
# One-time: build a venv with lnbits (dev) + test deps + fava
nix-shell -p uv --run "uv venv /tmp/libra-test-venv --python 3.12 && \
uv pip install --python /tmp/libra-test-venv/bin/python \
-e ~/dev/lnbits/dev pytest asgi-lifespan fava"
# Run (each invocation gets a fresh data folder — REQUIRED, see gotchas)
cd ~/dev/lnbits/dev && \
env LNBITS_KEY_MASTER=$(openssl rand -hex 32) \
LNBITS_DATA_FOLDER=$(mktemp -d -t libra-test-data-XXXX) \
LNBITS_EXTENSIONS_PATH=$HOME/dev/shared \
PYTHONPATH=$HOME/dev/shared/extensions:. \
PATH=/tmp/libra-test-venv/bin:$PATH \
/tmp/libra-test-venv/bin/pytest ~/dev/shared/extensions/libra/tests -q
```
```bash
# Smoke test only (validate the harness before running everything)
... pytest path/to/libra/tests/test_smoke.py
# One area
... pytest path/to/libra/tests/test_balances_api.py
# Single test, verbose
... pytest path/to/libra/tests/test_balances_api.py::test_mixed_income_expense_nets_correctly -v
```
### Environment gotchas (each cost a failed run on 2026-06-12)
- **`LNBITS_EXTENSIONS_PATH` is the *parent* of an `extensions/` dir** —
lnbits scans `{path}/extensions/` (`lnbits/app.py`,
`build_all_installed_extensions_list`). For extensions at
`~/dev/shared/extensions/libra`, pass `~/dev/shared`. Pointing it at
`~/dev/shared/extensions` makes libra invisible: zero extensions install,
migrations never run, and every test errors with
`no such table: extension_settings`.
- **Set `LNBITS_DATA_FOLDER` to a fresh temp dir explicitly.** The
conftest's `os.environ.setdefault` redirect is not always effective;
reusing a previous run's database fails `first_install` with
"Username already exists" during app-fixture setup.
- **`LNBITS_KEY_MASTER` (32-byte hex) is mandatory on lnbits dev** — the
signer migration aborts startup without it (issue lnbits#9
encrypt-at-rest). Any random value is fine for tests.
- **lnbits `main` does not work**: extensions importing
`lnbits.core.signers` fail to load, and libra's app fixture errors.
The Fava subprocess starts once per session (~1-2s) and is shared across tests; each test creates its own LNbits user so the shared ledger doesn't cause inter-test interference.
## Conventions
- **Tests assert intent, not shape.** Use the helpers in `helpers.py` for the request and assert on the *meaning* of the response (balance values, account names, settlement state), not on incidental keys in the JSON. This keeps tests resilient to non-behavioural API tweaks.
- **Currency-handling assertions use `pytest.approx`** for `Decimal`/`float` tolerance.
- **One canonical happy path per flow, plus boundary cases that matter** (voided entries excluded, pending entries excluded, cross-user isolation, auth gate rejection). Don't over-matrix.
- **Each test creates its own users** via the function-scoped `libra_user` / `libra_user_b` fixtures. The ledger is session-shared and accumulates entries; test isolation comes from unique user IDs, not ledger resets.

0
tests/__init__.py Normal file
View file

714
tests/conftest.py Normal file
View file

@ -0,0 +1,714 @@
"""Libra test infrastructure.
Brings up:
- A session-scoped Fava subprocess against a temp .beancount ledger
- A session-scoped LNbits FastAPI app with Libra extension activated
- The Libra FavaClient pointed at the test Fava instance
- Function-scoped user/wallet fixtures, plus a session-scoped superuser
Run from the LNbits source root::
PYTHONPATH=. pytest lnbits/extensions/libra/tests
Requires the `fava` binary on PATH. On NixOS::
nix-shell -p python3Packages.fava --run "pytest lnbits/extensions/libra/tests"
"""
import os
import tempfile
# IMPORTANT: configure the LNbits data folder BEFORE importing anything from
# lnbits. `lnbits/db.py` constructs Database instances at module-import time
# and freezes `settings.lnbits_data_folder` at that moment — overriding it in
# a fixture later is too late to redirect the SQLite files.
_SESSION_DATA_DIR = tempfile.mkdtemp(prefix="libra-lnbits-data-")
os.environ.setdefault("LNBITS_DATA_FOLDER", _SESSION_DATA_DIR)
# Lightning-invoice tests need a non-VoidWallet backend, but switching to
# FakeWallet here causes the LifespanManager teardown to hang indefinitely
# (the Lightning subsystem's background tasks don't unwind cleanly under
# anyio's TestRunner). Keeping VoidWallet — Lightning-invoice-generation
# tests are marked `skip` until a separate LN-harness strategy lands.
import asyncio # noqa: E402
import copy # noqa: E402
import inspect # noqa: E402
import shutil # noqa: E402
import socket # noqa: E402
import subprocess # noqa: E402
import time # noqa: E402
from pathlib import Path # noqa: E402
from typing import AsyncIterator, Iterator # noqa: E402
from uuid import uuid4 # noqa: E402
import httpx
import pytest
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from lnbits.app import create_app
from lnbits.core.crud import (
create_wallet,
delete_account,
get_user,
)
from lnbits.core.models.users import UpdateSuperuserPassword
from lnbits.core.services import create_user_account
from lnbits.core.views.auth_api import first_install
from lnbits.settings import AuthMethods, EditableSettings, Settings
from lnbits.settings import settings as lnbits_settings
LEDGER_SLUG = "libra-test"
# ---------------------------------------------------------------------------
# Settings overrides
# ---------------------------------------------------------------------------
_PURE_SETTINGS = copy.deepcopy(lnbits_settings)
_PURE_SETTINGS_FIELDS = tuple(
sorted(
{
f
for f in Settings.readonly_fields()
if f != "super_user"
}
| {
name
for name in inspect.signature(EditableSettings).parameters
if not name.startswith("_")
}
)
)
def _settings_cleanup(settings: Settings) -> None:
"""Reset mutable settings to their pre-test snapshot, then re-apply
test-specific overrides on top so each test starts from the same baseline.
Mirrors the shape of lnbits/main/tests/conftest.py: restore PURE, then
set the values the tests rely on. Without this, autouse cleanup wipes
out everything the session-scoped `settings` fixture set up.
"""
for field in _PURE_SETTINGS_FIELDS:
setattr(settings, field, getattr(_PURE_SETTINGS, field))
# Test-specific overrides — these must survive cleanup between tests.
settings.auth_https_only = False
settings.lnbits_data_folder = _SESSION_DATA_DIR
settings.lnbits_admin_extensions = [] # libra is a multi-user extension, not admin-only
settings.lnbits_admin_ui = True
settings.lnbits_extensions_default_install = []
settings.lnbits_extensions_deactivate_all = False
settings.lnbits_allow_new_accounts = True
settings.lnbits_allowed_users = []
settings.auth_allowed_methods = AuthMethods.all()
settings.auth_credetials_update_threshold = 120
settings.lnbits_require_user_activation = False
settings.lnbits_user_activation_by_invitation_code = False
settings.lnbits_register_reusable_activation_code = ""
settings.lnbits_register_one_time_activation_codes = []
# Keep the rate limiter disabled across per-test settings resets (the
# limiter itself is fixed at app-creation time, but keep the value coherent).
settings.lnbits_rate_limit_no = 1_000_000
@pytest.fixture(scope="session")
def anyio_backend() -> str:
return "asyncio"
@pytest.fixture(scope="session")
def settings() -> Iterator[Settings]:
"""LNbits settings configured for the libra test session.
Mirrors lnbits/main/tests/conftest.py: do NOT pre-set super_user; the boot
sequence assigns a UUID and creates the matching account. The `super_user`
fixture reads settings.super_user after first_install completes.
The data folder was set via LNBITS_DATA_FOLDER at the top of this module
so the lnbits/db.py import-time directory creation lands in the right
place; nothing to do here except make sure it stays consistent.
"""
lnbits_settings.auth_https_only = False
lnbits_settings.lnbits_admin_extensions = ["libra"]
lnbits_settings.lnbits_data_folder = _SESSION_DATA_DIR
lnbits_settings.lnbits_admin_ui = True
lnbits_settings.lnbits_extensions_default_install = []
lnbits_settings.lnbits_extensions_deactivate_all = False
# The full suite fires >200 requests/minute; the default rate limit (200/min)
# otherwise 429s fixture setup intermittently. The limiter is built once at
# app creation from this value (lnbits/app.py register_new_ratelimiter), and
# this fixture runs before the `app` fixture, so raising it here disables it
# for the session.
lnbits_settings.lnbits_rate_limit_no = 1_000_000
yield lnbits_settings
@pytest.fixture(autouse=True)
def _per_test_settings_reset(settings: Settings) -> Iterator[None]:
_settings_cleanup(settings)
yield
_settings_cleanup(settings)
# ---------------------------------------------------------------------------
# Fava subprocess
# ---------------------------------------------------------------------------
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
MINIMAL_LEDGER = """; Test ledger for Libra extension integration tests
; Title must slugify to match LEDGER_SLUG Fava derives the URL slug from this.
option "title" "libra-test"
option "operating_currency" "EUR"
option "operating_currency" "SATS"
option "render_commas" "TRUE"
2020-01-01 commodity EUR
2020-01-01 commodity SATS
2020-01-01 open Assets:Lightning:Balance EUR,SATS
2020-01-01 open Assets:Bitcoin:Lightning EUR,SATS
2020-01-01 open Assets:Cash EUR,SATS
2020-01-01 open Equity:Opening-Balances EUR,SATS
2020-01-01 open Income:Generic EUR,SATS
2020-01-01 open Expenses:Generic EUR,SATS
include "accounts/chart.beancount"
include "accounts/users.beancount"
"""
# Split-layout include targets, mirroring the production fava layout
# (aiolabs/server-deploy#4). libra's fava_client routes Open directives by
# account name (fava_client._infer_target_file): per-user accounts
# (:User-xxxxxxxx) to accounts/users.beancount, everything else to
# accounts/chart.beancount. Both must exist as Fava *source* files (i.e. be
# included) or /api/source writes 500 with "non-source file". The title stays
# in the root ledger above so Fava's slug still matches LEDGER_SLUG (scalar
# options don't propagate from includes — see aiolabs/server-deploy#9).
CHART_SEED = "; Admin-mutable chart of accounts (libra appends Open directives).\n"
USERS_SEED = "; Per-user account opens (libra appends at signup).\n"
@pytest.fixture(scope="session")
def fava_ledger_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Session-scoped split ledger Fava reads from: a root file that includes
accounts/chart.beancount (admin add-account target) and
accounts/users.beancount (per-user opens target)."""
ledger_dir = tmp_path_factory.mktemp("libra-ledger")
(ledger_dir / "accounts").mkdir()
(ledger_dir / "accounts" / "chart.beancount").write_text(CHART_SEED)
(ledger_dir / "accounts" / "users.beancount").write_text(USERS_SEED)
ledger = ledger_dir / f"{LEDGER_SLUG}.beancount"
ledger.write_text(MINIMAL_LEDGER)
return ledger
@pytest.fixture(scope="session")
def fava_process(fava_ledger_path: Path) -> Iterator[str]:
"""Spawn fava as a subprocess, yield its base URL, terminate on teardown."""
fava_bin = shutil.which("fava")
if not fava_bin:
pytest.skip(
"fava not found on PATH; "
"install with `pip install fava` or `nix-shell -p python3Packages.fava`"
)
port = _find_free_port()
base_url = f"http://127.0.0.1:{port}"
proc = subprocess.Popen(
[
fava_bin,
"--host", "127.0.0.1",
"--port", str(port),
str(fava_ledger_path),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env={**os.environ, "BEANCOUNT_FILE": str(fava_ledger_path)},
)
deadline = time.monotonic() + 15.0
ready = False
while time.monotonic() < deadline:
if proc.poll() is not None:
raise RuntimeError(
f"fava exited early with returncode {proc.returncode}"
)
try:
r = httpx.get(f"{base_url}/{LEDGER_SLUG}/api/changed", timeout=0.5)
if r.status_code == 200:
ready = True
break
except httpx.RequestError:
pass
time.sleep(0.1)
if not ready:
proc.terminate()
raise RuntimeError("fava did not become ready within 15s")
try:
yield base_url
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
# ---------------------------------------------------------------------------
# LNbits app + Libra extension
# ---------------------------------------------------------------------------
def _import_libra(submodule: str):
"""Import a libra submodule under whichever path the active LNbits setup uses.
LNbits resolves an extension's module name dynamically: `lnbits.extensions.<ext>`
when extensions live in the default `lnbits/extensions/` directory, or just
`<ext>` when `LNBITS_EXTENSIONS_PATH` points elsewhere. Tests should work in
both setups.
"""
import importlib
for prefix in ("lnbits.extensions.libra", "libra"):
try:
return importlib.import_module(f"{prefix}.{submodule}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(
f"libra.{submodule}: tried 'lnbits.extensions.libra.{submodule}' and "
f"'libra.{submodule}'. Is LNBITS_EXTENSIONS_PATH pointing at the libra parent dir, "
f"or is libra symlinked into lnbits/extensions/?"
)
async def _enable_libra_for_user(user_id: str) -> None:
"""Set libra to active in the user_extensions table for `user_id`.
LNbits gates every extension API path through `check_user_extension_access`,
which requires the calling user to have the extension marked active in
`user_extensions`. New accounts have no extensions enabled, so the API
rejects them with 403 until we flip the row.
"""
from lnbits.core.services.users import update_user_extensions
await update_user_extensions(user_id, ["libra"])
async def _activate_libra(fava_url: str, super_user_id: str) -> None:
"""Point libra at the test Fava instance and enable it for the superuser.
Libra is auto-discovered + auto-installed at LNbits boot via
`LNBITS_EXTENSIONS_PATH`, so its router is already mounted, migrations
already ran, and `libra_start()` already initialised a FavaClient with
the default `http://localhost:3333/libra-ledger` URL. Three things still
need doing:
1. Redirect the FavaClient at the test Fava instance.
2. Persist the override in `extension_settings` so any caller that goes
through `services.get_settings()` picks it up too.
3. Enable libra for the superuser per-user activation isn't automatic.
"""
libra_fava_client = _import_libra("fava_client")
libra_crud = _import_libra("crud")
libra_fava_client.init_fava_client(
fava_url=fava_url,
ledger_slug=LEDGER_SLUG,
timeout=5.0,
)
await libra_crud.db.execute("DELETE FROM extension_settings")
await libra_crud.db.execute(
"""
INSERT INTO extension_settings (id, fava_url, fava_ledger_slug, fava_timeout)
VALUES (:id, :fava_url, :slug, :timeout)
""",
{
"id": uuid4().hex,
"fava_url": fava_url,
"slug": LEDGER_SLUG,
"timeout": 5.0,
},
)
await _enable_libra_for_user(super_user_id)
@pytest.fixture(scope="session")
async def app(settings: Settings, fava_process: str) -> AsyncIterator:
"""Session-scoped LNbits app with Libra activated."""
app = create_app()
# First-time startup runs all core + libra migrations (~3-5s on cold disk),
# plus libra_start() initialises the Fava client and background tasks.
# Bump the timeout well above asgi_lifespan's 10s default so a slow
# migration step or Fava startup race doesn't spuriously fail the session.
async with LifespanManager(app, startup_timeout=60, shutdown_timeout=20) as manager:
settings.first_install = True
# pragma: allowlist secret start
await first_install(
UpdateSuperuserPassword(
username="superadmin",
password="secret1234",
password_repeat="secret1234",
first_install_token=settings.first_install_token,
)
# pragma: allowlist secret end
)
await _activate_libra(
fava_url=fava_process,
super_user_id=settings.super_user,
)
yield manager.app
@pytest.fixture(scope="session")
async def client(app, settings: Settings) -> AsyncIterator[AsyncClient]:
url = f"http://{settings.host}:{settings.port}"
async with AsyncClient(transport=ASGITransport(app=app), base_url=url) as client:
yield client
# ---------------------------------------------------------------------------
# Users
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
async def super_user(app, settings: Settings):
"""The superadmin account created by first_install."""
# first_install sets settings.super_user to the actual ID it created.
user = await get_user(settings.super_user)
assert user is not None, "superadmin was not created by first_install"
return user
@pytest.fixture
async def libra_user(app):
"""A fresh non-admin user with a wallet. Function-scoped — each test gets its own.
Libra is enabled in the user_extensions table for this user so the API
doesn't 403 with "Extension 'libra' not enabled."
"""
user = await create_user_account()
wallet = await create_wallet(
user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}"
)
await _enable_libra_for_user(user.id)
yield user, wallet
# Cleanup: best-effort
try:
await delete_account(user.id)
except Exception:
pass
@pytest.fixture
async def libra_user_b(app):
"""A second fresh non-admin user, for tests that need cross-user assertions."""
user = await create_user_account()
wallet = await create_wallet(
user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}"
)
await _enable_libra_for_user(user.id)
yield user, wallet
try:
await delete_account(user.id)
except Exception:
pass
# ---------------------------------------------------------------------------
# Auth headers
# ---------------------------------------------------------------------------
async def _user_bearer(client: AsyncClient, user_id: str) -> dict:
"""Bearer headers for a non-admin user via the `/auth/usr` user-id-only flow.
Admin/super accounts are blocked from this flow (LNbits forces them to
use username+password); regular users use it freely. Required for libra
endpoints that depend on `check_user_exists` (Bearer/cookie/usr) rather
than on a wallet API key.
"""
r = await client.post("/api/v1/auth/usr", json={"usr": user_id})
client.cookies.clear()
token = r.json().get("access_token")
assert token, f"user-id login failed: {r.status_code} {r.text}"
return {
"Authorization": f"Bearer {token}",
"Content-type": "application/json",
}
async def _superadmin_bearer(client: AsyncClient) -> dict:
"""Bearer headers for the superadmin via username+password auth.
`/api/v1/auth/usr` (user-id-only auth) is rejected for admin users
LNbits enforces username+password for accounts in `lnbits_admin_users`
or the super_user account. So super-user fixtures use the username
flow that `first_install` configured.
"""
r = await client.post(
"/api/v1/auth", json={"username": "superadmin", "password": "secret1234"}
)
client.cookies.clear()
token = r.json().get("access_token")
assert token, f"superadmin login failed: {r.status_code} {r.text}"
return {
"Authorization": f"Bearer {token}",
"Content-type": "application/json",
}
@pytest.fixture
async def super_user_bearer_headers(client: AsyncClient, super_user) -> dict:
"""Bearer headers for the few endpoints that use LNbits `check_super_user`.
The `/libra/api/v1/settings` endpoints (and other libra paths that take
`User = Depends(check_super_user)`) require a Bearer token from
username+password login. Most other libra admin endpoints use the
wallet-admin-key auth flow use `super_user_headers` for those.
"""
return await _superadmin_bearer(client)
@pytest.fixture
async def super_user_headers(super_user, libra_wallet) -> dict:
"""Admin-key headers for libra admin endpoints that use the wallet auth flow.
Libra's `require_super_user` dependency takes a `WalletTypeInfo` via
`require_admin_key` and verifies the wallet's owner is the LNbits
super user. So we authenticate by sending the super-user-owned wallet's
admin key as `X-Api-Key`.
"""
return admin_key_headers(libra_wallet)
def invoice_key_headers(wallet) -> dict:
"""Wallet invoice-key headers (X-Api-Key) — for require_invoice_key endpoints."""
return {"X-Api-Key": wallet.inkey, "Content-type": "application/json"}
def admin_key_headers(wallet) -> dict:
"""Wallet admin-key headers (X-Api-Key) — for require_admin_key endpoints."""
return {"X-Api-Key": wallet.adminkey, "Content-type": "application/json"}
# ---------------------------------------------------------------------------
# Libra-specific session setup: wallet, accounts
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
async def libra_wallet(
app, settings: Settings, super_user, fava_process: str, client: AsyncClient,
):
"""Session-scoped: create a wallet for the super user and register it
as the libra wallet in extension_settings.
Most flows (expense, income, settle, pay-user) refuse to operate until
this is set. Session-scoped because it's a one-time setup that any test
can share.
"""
wallet = await create_wallet(user_id=super_user.id, wallet_name="libra-main")
# Configure libra_wallet_id via the settings API so the in-memory cache
# (services.update_settings) refreshes too.
#
# Critical: include fava_url + fava_ledger_slug in the body so that
# services.update_settings()'s re-init of the FavaClient doesn't reset
# us to the default `http://localhost:3333/libra-ledger`. The settings
# endpoint rewrites the global FavaClient from the body's contents on
# every call.
headers = await _superadmin_bearer(client)
r = await client.put(
"/libra/api/v1/settings",
headers=headers,
json={
"libra_wallet_id": wallet.id,
"fava_url": fava_process,
"fava_ledger_slug": LEDGER_SLUG,
},
)
assert r.status_code == 200, f"libra_wallet setup failed: {r.status_code} {r.text}"
return wallet
@pytest.fixture(scope="session")
async def standard_accounts(app, super_user, libra_wallet, client: AsyncClient):
"""Session-scoped: create a small set of accounts used across tests.
Returns a dict of {short_name: account_dict}. Each account has at least
`id` and `name` keys.
"""
# `/accounts` POST is gated by `require_super_user` (libra-level, wallet
# admin-key flow), so we authenticate with the super-user's wallet key.
headers = admin_key_headers(libra_wallet)
async def _list_lookup(name: str) -> dict | None:
r = await client.get("/libra/api/v1/accounts", headers=headers)
if r.status_code != 200:
return None
for a in r.json():
if a.get("name") == name:
return a
return None
async def _create(name: str, account_type: str) -> dict:
# Get-then-create. Some accounts (Assets:Cash, etc.) are auto-synced
# into libra's DB from the Beancount Open directives by the account-sync
# background task. Posting a duplicate raises IntegrityError → 500;
# checking first avoids the race and the noisy error log.
existing = await _list_lookup(name)
if existing:
return existing
r = await client.post(
"/libra/api/v1/accounts",
headers=headers,
json={"name": name, "account_type": account_type},
)
if r.status_code == 201:
return r.json()
# Lost the race between our GET and POST — sync ran in between.
existing = await _list_lookup(name)
if existing:
return existing
raise AssertionError(f"create account {name}: {r.status_code} {r.text}")
return {
"expense_food": await _create("Expenses:Test:Food", "expense"),
"expense_supplies": await _create("Expenses:Test:Supplies", "expense"),
"revenue_rent": await _create("Income:Test:Rent", "revenue"),
"revenue_fees": await _create("Income:Test:Fees", "revenue"),
# Cash for revenue/settlement payment-method tests. Already declared
# as an Open directive in the Beancount file (see MINIMAL_LEDGER),
# but needs a libra-DB row too because the revenue endpoint validates
# payment-method-account via libra's local lookup.
"assets_cash": await _create("Assets:Cash", "asset"),
# Lightning balance account — the manual-payment-request approve
# endpoint posts the payment leg against this. Open directive lives
# in MINIMAL_LEDGER's Assets:Lightning:Balance, but the API code
# looks up `Assets:Bitcoin:Lightning` specifically.
"assets_lightning": await _create("Assets:Bitcoin:Lightning", "asset"),
}
# ---------------------------------------------------------------------------
# Configured user — wallet set + can submit expenses to the standard accounts
# ---------------------------------------------------------------------------
async def _grant_account_permissions(
client: AsyncClient,
libra_wallet,
user_id: str,
grants: list[tuple[str, str]],
) -> None:
"""Grant a list of (account_id, permission_type) pairs to a user.
Existing perms come back as 409; that's idempotent for fixture re-runs.
"""
headers = admin_key_headers(libra_wallet)
for account_id, permission_type in grants:
r = await client.post(
"/libra/api/v1/admin/permissions",
headers=headers,
json={
"user_id": user_id,
"account_id": account_id,
"permission_type": permission_type,
},
)
# 201 created; 409 if it already existed (idempotent).
assert r.status_code in (200, 201, 409), (
f"grant permission failed: {r.status_code} {r.text}"
)
@pytest.fixture
async def configured_user(
app, super_user, libra_wallet, standard_accounts, client: AsyncClient,
):
"""Function-scoped: fresh user with a wallet, configured for libra,
permitted to submit expenses to the standard test accounts.
Yields (user, wallet) ready to make any user-facing API call.
"""
user = await create_user_account()
wallet = await create_wallet(
user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}"
)
await _enable_libra_for_user(user.id)
# User registers their own wallet with libra. The endpoint uses
# `check_user_exists` which accepts either a Bearer access token OR
# a `?usr=<id>` query param — we use the query param to avoid the
# cookie-state interleaving that bites when two configured_user
# fixtures stack in the same test.
r = await client.put(
f"/libra/api/v1/user/wallet?usr={user.id}",
json={"user_wallet_id": wallet.id},
)
assert r.status_code == 200, f"user wallet setup failed: {r.status_code} {r.text}"
# Grant submit_expense on every expense account, submit_income on every
# revenue account, so tests can hit either user-side entry endpoint.
grants = [
(a["id"], "submit_expense")
for k, a in standard_accounts.items() if k.startswith("expense_")
] + [
(a["id"], "submit_income")
for k, a in standard_accounts.items() if k.startswith("revenue_")
]
await _grant_account_permissions(client, libra_wallet, user.id, grants)
yield user, wallet
try:
await delete_account(user.id)
except Exception:
pass
@pytest.fixture
async def configured_user_b(
app, super_user, libra_wallet, standard_accounts, client: AsyncClient,
):
"""A second configured user for cross-user tests."""
user = await create_user_account()
wallet = await create_wallet(
user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}"
)
await _enable_libra_for_user(user.id)
r = await client.put(
f"/libra/api/v1/user/wallet?usr={user.id}",
json={"user_wallet_id": wallet.id},
)
assert r.status_code == 200, f"user wallet setup failed: {r.status_code} {r.text}"
grants = [
(a["id"], "submit_expense")
for k, a in standard_accounts.items() if k.startswith("expense_")
] + [
(a["id"], "submit_income")
for k, a in standard_accounts.items() if k.startswith("revenue_")
]
await _grant_account_permissions(client, libra_wallet, user.id, grants)
yield user, wallet
try:
await delete_account(user.id)
except Exception:
pass

428
tests/helpers.py Normal file
View file

@ -0,0 +1,428 @@
"""Convenience helpers for Libra integration tests.
Wrap the most common multi-step flows so each test reads as a sequence of
intentions rather than as a sequence of HTTP calls. Every helper returns the
parsed JSON response and asserts a successful status code tests that want
to assert on failures should call the endpoint directly.
All amounts are passed as Decimal (or numeric string). Currency goes as a
separate ISO code field this matches `models.ExpenseEntry` / `ReceivableEntry`
/ `SettleReceivable` / `PayUser` etc., which all carry `amount: Decimal` and
`currency: Optional[str]` independently.
"""
from decimal import Decimal
from typing import Any, Optional, Union
from httpx import AsyncClient, Response
Amount = Union[Decimal, int, float, str]
def _amount(value: Amount) -> str:
"""Coerce amount to a JSON-serialisable string Pydantic will parse as Decimal."""
return str(value)
# ---------------------------------------------------------------------------
# Setup — libra wallet + per-user wallet + accounts + permissions
# ---------------------------------------------------------------------------
async def configure_libra_wallet(
client: AsyncClient,
*,
super_user_headers: dict,
libra_wallet_id: str,
) -> dict:
"""Super user sets the libra wallet (required before any entry endpoint works)."""
r = await client.put(
"/libra/api/v1/settings",
headers=super_user_headers,
json={"libra_wallet_id": libra_wallet_id},
)
assert r.status_code == 200, f"configure_libra_wallet failed: {r.status_code} {r.text}"
return r.json()
async def configure_user_wallet(
client: AsyncClient,
*,
wallet_inkey: str,
user_wallet_id: str,
) -> dict:
"""User sets their personal wallet (required before they can submit entries)."""
r = await client.put(
"/libra/api/v1/user/wallet",
headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"},
json={"user_wallet_id": user_wallet_id},
)
assert r.status_code == 200, f"configure_user_wallet failed: {r.status_code} {r.text}"
return r.json()
async def create_account(
client: AsyncClient,
*,
super_user_headers: dict,
name: str,
account_type: str,
description: Optional[str] = None,
) -> dict:
"""Super user creates an account in the libra local DB.
`account_type` is one of "asset", "liability", "equity", "revenue", "expense".
"""
r = await client.post(
"/libra/api/v1/accounts",
headers=super_user_headers,
json={
"name": name,
"account_type": account_type,
"description": description,
},
)
assert r.status_code == 201, f"create_account failed: {r.status_code} {r.text}"
return r.json()
async def grant_permission(
client: AsyncClient,
*,
super_user_headers: dict,
user_id: str,
account_id: str,
permission_type: str = "submit_expense",
) -> dict:
r = await client.post(
"/libra/api/v1/admin/permissions",
headers=super_user_headers,
json={
"user_id": user_id,
"account_id": account_id,
"permission_type": permission_type,
},
)
assert r.status_code == 201, f"grant_permission failed: {r.status_code} {r.text}"
return r.json()
async def add_chart_account(
client: AsyncClient,
*,
super_user_headers: dict,
name: str,
description: Optional[str] = None,
) -> Response:
"""Super user adds a chart-of-accounts entry via the admin endpoint
(POST /api/v1/admin/accounts). Returns the raw Response so callers can
assert on status codes (201 / 400 / 409 / 403)."""
body: dict[str, Any] = {"name": name}
if description is not None:
body["description"] = description
return await client.post(
"/libra/api/v1/admin/accounts",
headers=super_user_headers,
json=body,
)
# ---------------------------------------------------------------------------
# Entries — user side
# ---------------------------------------------------------------------------
async def post_expense(
client: AsyncClient,
*,
wallet_inkey: str,
user_wallet_id: str,
amount: Amount,
description: str,
expense_account: str,
currency: Optional[str] = "EUR",
is_equity: bool = False,
reference: Optional[str] = None,
) -> dict[str, Any]:
"""User submits an expense — creates Liability (libra owes user) or Equity contribution.
Returns the created JournalEntry payload.
"""
r = await client.post(
"/libra/api/v1/entries/expense",
headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"},
json={
"description": description,
"amount": _amount(amount),
"expense_account": expense_account,
"user_wallet": user_wallet_id,
"currency": currency,
"is_equity": is_equity,
"reference": reference,
},
)
assert r.status_code == 201, f"post_expense failed: {r.status_code} {r.text}"
return r.json()
async def post_income(
client: AsyncClient,
*,
wallet_inkey: str,
amount: Amount,
description: str,
revenue_account: str,
currency: str = "EUR",
reference: Optional[str] = None,
) -> dict[str, Any]:
"""User submits income on libra's behalf — creates Receivable (user owes libra)."""
r = await client.post(
"/libra/api/v1/entries/income",
headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"},
json={
"description": description,
"amount": _amount(amount),
"revenue_account": revenue_account,
"currency": currency,
"reference": reference,
},
)
assert r.status_code == 201, f"post_income failed: {r.status_code} {r.text}"
return r.json()
async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> dict[str, Any]:
r = await client.get(
"/libra/api/v1/entries/user",
headers={"X-Api-Key": wallet_inkey},
)
assert r.status_code == 200, f"list_user_entries failed: {r.status_code} {r.text}"
return r.json()
async def list_pending_entries(
client: AsyncClient, *, super_user_headers: dict,
) -> list[dict]:
"""Admin lists pending (`!`) entries awaiting approval."""
r = await client.get(
"/libra/api/v1/entries/pending",
headers=super_user_headers,
)
assert r.status_code == 200, f"list_pending_entries failed: {r.status_code} {r.text}"
return r.json()
# ---------------------------------------------------------------------------
# Entries — admin side
# ---------------------------------------------------------------------------
async def post_receivable(
client: AsyncClient,
*,
super_user_headers: dict,
user_id: str,
amount: Amount,
description: str,
revenue_account: str,
currency: str = "EUR",
) -> dict[str, Any]:
"""Admin records a receivable — user owes libra."""
r = await client.post(
"/libra/api/v1/entries/receivable",
headers=super_user_headers,
json={
"user_id": user_id,
"amount": _amount(amount),
"description": description,
"revenue_account": revenue_account,
"currency": currency,
},
)
assert r.status_code == 201, f"post_receivable failed: {r.status_code} {r.text}"
return r.json()
async def post_revenue(
client: AsyncClient,
*,
super_user_headers: dict,
amount: Amount,
description: str,
revenue_account: str,
payment_method_account: str,
currency: str = "EUR",
) -> dict[str, Any]:
r = await client.post(
"/libra/api/v1/entries/revenue",
headers=super_user_headers,
json={
"amount": _amount(amount),
"description": description,
"revenue_account": revenue_account,
"payment_method_account": payment_method_account,
"currency": currency,
},
)
assert r.status_code == 201, f"post_revenue failed: {r.status_code} {r.text}"
return r.json()
# ---------------------------------------------------------------------------
# Balances
# ---------------------------------------------------------------------------
async def get_balance(client: AsyncClient, *, wallet_inkey: str) -> dict[str, Any]:
"""Calling user's balance (or libra total if invoked by super user)."""
r = await client.get(
"/libra/api/v1/balance",
headers={"X-Api-Key": wallet_inkey},
)
assert r.status_code == 200, f"get_balance failed: {r.status_code} {r.text}"
return r.json()
async def get_all_balances(
client: AsyncClient, *, super_user_headers: dict
) -> list[dict]:
r = await client.get(
"/libra/api/v1/balances/all",
headers=super_user_headers,
)
assert r.status_code == 200, f"get_all_balances failed: {r.status_code} {r.text}"
return r.json()
# ---------------------------------------------------------------------------
# Settlement
# ---------------------------------------------------------------------------
async def settle_receivable(
client: AsyncClient,
*,
super_user_headers: dict,
user_id: str,
amount: Amount,
description: str = "Cash settlement",
payment_method: str = "cash",
currency: str = "EUR",
) -> dict[str, Any]:
"""Admin records that user paid libra (e.g. cash, bank transfer)."""
r = await client.post(
"/libra/api/v1/receivables/settle",
headers=super_user_headers,
json={
"user_id": user_id,
"amount": _amount(amount),
"description": description,
"payment_method": payment_method,
"currency": currency,
},
)
assert r.status_code == 200, f"settle_receivable failed: {r.status_code} {r.text}"
return r.json()
async def pay_user(
client: AsyncClient,
*,
super_user_headers: dict,
user_id: str,
amount: Amount,
description: str = "Libra pays user",
payment_method: str = "cash",
currency: str = "EUR",
) -> dict[str, Any]:
"""Admin records that libra paid user (e.g. cash, bank, lightning)."""
r = await client.post(
"/libra/api/v1/payables/pay",
headers=super_user_headers,
json={
"user_id": user_id,
"amount": _amount(amount),
"description": description,
"payment_method": payment_method,
"currency": currency,
},
)
assert r.status_code == 200, f"pay_user failed: {r.status_code} {r.text}"
return r.json()
# ---------------------------------------------------------------------------
# Manual payment requests
# ---------------------------------------------------------------------------
async def submit_manual_payment_request(
client: AsyncClient,
*,
wallet_inkey: str,
amount_sats: int,
description: str,
) -> dict[str, Any]:
"""User asks for libra to pay them via a manual (non-Lightning) route.
Body matches `CreateManualPaymentRequest`: amount in satoshis (no fiat
conversion at this endpoint), description for the admin to review.
"""
r = await client.post(
"/libra/api/v1/manual-payment-request",
headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"},
json={"amount": amount_sats, "description": description},
)
assert r.status_code in (200, 201), (
f"submit_manual_payment_request failed: {r.status_code} {r.text}"
)
return r.json()
async def approve_manual_payment_request(
client: AsyncClient, *, super_user_headers: dict, request_id: str,
) -> dict[str, Any]:
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{request_id}/approve",
headers=super_user_headers,
)
assert r.status_code == 200, (
f"approve_manual_payment_request failed: {r.status_code} {r.text}"
)
return r.json()
async def approve_entry(
client: AsyncClient, *, super_user_headers: dict, entry_id: str,
) -> dict[str, Any]:
"""Admin approves a pending journal entry, flipping its flag from `!` to `*`."""
r = await client.post(
f"/libra/api/v1/entries/{entry_id}/approve",
headers=super_user_headers,
)
assert r.status_code == 200, f"approve_entry failed: {r.status_code} {r.text}"
return r.json()
async def reject_entry(
client: AsyncClient, *, super_user_headers: dict, entry_id: str,
) -> dict[str, Any]:
"""Admin rejects a pending journal entry, marking it #voided."""
r = await client.post(
f"/libra/api/v1/entries/{entry_id}/reject",
headers=super_user_headers,
)
assert r.status_code == 200, f"reject_entry failed: {r.status_code} {r.text}"
return r.json()
async def reject_manual_payment_request(
client: AsyncClient, *, super_user_headers: dict, request_id: str,
) -> dict[str, Any]:
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{request_id}/reject",
headers=super_user_headers,
)
assert r.status_code == 200, (
f"reject_manual_payment_request failed: {r.status_code} {r.text}"
)
return r.json()

View file

@ -0,0 +1,170 @@
"""Admin chart-of-accounts endpoint — POST /api/v1/admin/accounts.
Covers the endpoint wired into the UI's "Add Account" dialog:
- Writes an Open directive to accounts/chart.beancount via Fava /api/source,
*unconstrained* by currency (the directive needs no currency list), with
provenance + description metadata (escaped for Beancount).
- Mirrors the account into libra's DB (synced_to_libra_db).
- Rejects duplicates with 409, malformed names with 400, and non-super-users
with 403.
The harness ledger is the split layout (root includes accounts/chart.beancount)
so the endpoint's hardcoded target_file resolves — see conftest.CHART_SEED.
"""
import re
from pathlib import Path
from uuid import uuid4
import pytest
from .helpers import add_chart_account
def _chart_text(fava_ledger_path: Path) -> str:
return (fava_ledger_path.parent / "accounts" / "chart.beancount").read_text()
def _unique(prefix: str = "Expenses:Test") -> str:
# Capitalized leaf (valid Beancount component) unique per call so the
# session-scoped ledger doesn't collide across tests.
return f"{prefix}:T{uuid4().hex[:8].upper()}"
@pytest.mark.anyio
async def test_add_chart_account_writes_unconstrained_open_with_escaped_meta(
client, super_user_headers, fava_ledger_path,
):
"""Happy path: 201, the Open directive carries no currency constraint, the
description metadata is escaped, and the account is synced into libra's DB."""
name = _unique()
r = await add_chart_account(
client,
super_user_headers=super_user_headers,
name=name,
description='has a "quote" and ok',
)
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
body = r.json()
assert body["account_name"] == name
assert body["synced_to_libra_db"] is True
chart = _chart_text(fava_ledger_path)
# Open present and UNCONSTRAINED: the account name is followed directly by
# end-of-line, not " EUR, SATS, USD".
assert re.search(rf"^\d{{4}}-\d{{2}}-\d{{2}} open {re.escape(name)}$", chart, re.MULTILINE), (
f"expected an unconstrained Open for {name}, chart was:\n{chart}"
)
# Description metadata is escaped so the quote can't break the ledger.
assert r'description: "has a \"quote\" and ok"' in chart
assert 'source: "admin-ui"' in chart
@pytest.mark.anyio
async def test_add_chart_account_with_explicit_currencies_constrains_open(
client, super_user_headers, fava_ledger_path,
):
"""API callers may still pass an explicit currency constraint (the UI never
does). When provided, it lands on the Open directive."""
name = _unique()
r = await client.post(
"/libra/api/v1/admin/accounts",
headers=super_user_headers,
json={"name": name, "currencies": ["EUR", "SATS"]},
)
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
chart = _chart_text(fava_ledger_path)
assert re.search(rf"open {re.escape(name)} EUR, SATS$", chart, re.MULTILINE), (
f"expected a currency-constrained Open for {name}, chart was:\n{chart}"
)
@pytest.mark.anyio
async def test_add_chart_account_duplicate_returns_409(
client, super_user_headers,
):
"""Adding the same account twice: first 201, second 409 (not a false success)."""
name = _unique()
first = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
assert first.status_code == 201, f"first add: {first.status_code} {first.text}"
second = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
assert second.status_code == 409, f"expected 409, got {second.status_code}: {second.text}"
assert "already exists" in second.json().get("detail", "").lower()
@pytest.mark.anyio
async def test_add_chart_account_recovers_ledger_only_account(
client, super_user_headers,
):
"""An account present in the ledger but absent from libra's DB (prior sync
failure / out-of-band edit) is recovered (synced), not 409'd — otherwise it
would be permanently un-grantable with no path back.
Reproduce the ledger-only state by creating normally (so Fava parses the
Open) then deleting only the libra-DB row appending to the ledger file
directly would race Fava's parse cache."""
from ..crud import db # the same singleton the app uses
name = _unique("Expenses:Recover")
first = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
assert first.status_code == 201, f"setup create failed: {first.status_code} {first.text}"
await db.execute("DELETE FROM accounts WHERE name = :name", {"name": name})
r = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
assert r.status_code == 201, f"expected 201 recovery, got {r.status_code}: {r.text}"
body = r.json()
assert body.get("already_existed") is True, body
assert body["synced_to_libra_db"] is True, body
@pytest.mark.anyio
async def test_add_chart_account_invalid_prefix_returns_400(
client, super_user_headers, fava_ledger_path,
):
"""A root outside the five valid types is rejected and never written."""
before = _chart_text(fava_ledger_path)
r = await add_chart_account(client, super_user_headers=super_user_headers, name="Foo:Bar")
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert _chart_text(fava_ledger_path) == before, "rejected account must not be written"
@pytest.mark.anyio
@pytest.mark.parametrize(
"bad_name",
[
"Expenses:Foo Bar", # space
"Expenses:foo", # lowercase sub-component start
"Expenses:Foo!", # punctuation
"Expenses:", # no sub-account
"Expenses:Foo::Bar", # empty component
],
)
async def test_add_chart_account_invalid_characters_returns_400(
client, super_user_headers, fava_ledger_path, bad_name,
):
"""Malformed account names are rejected server-side (the UI guard can be
bypassed via the API) and never reach the ledger."""
before = _chart_text(fava_ledger_path)
r = await add_chart_account(client, super_user_headers=super_user_headers, name=bad_name)
assert r.status_code == 400, f"expected 400 for {bad_name!r}, got {r.status_code}: {r.text}"
assert _chart_text(fava_ledger_path) == before, "rejected account must not be written"
@pytest.mark.anyio
async def test_add_chart_account_requires_super_user(
client, configured_user, fava_ledger_path,
):
"""A regular user's wallet admin-key passes require_admin_key but fails the
super-user identity check 403, nothing written."""
_user, wallet = configured_user
name = _unique()
before = _chart_text(fava_ledger_path)
r = await client.post(
"/libra/api/v1/admin/accounts",
headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"},
json={"name": name},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert _chart_text(fava_ledger_path) == before, "unauthorized add must not be written"

452
tests/test_balances_api.py Normal file
View file

@ -0,0 +1,452 @@
"""Balance display tests — the user-named "mixture of income and expenses
displayed correctly" scenario.
The balance API returns figures from libra's perspective:
- Negative `fiat_balances[CCY]` libra owes the user
- Positive `fiat_balances[CCY]` user owes libra
- Sum across Payable + Receivable + Credit per currency
(Credit added per libra-#41: overpayment lands as a liability that
libra owes the user going forward, naturally subtracting from net.)
Lifetime totals (`total_expenses_fiat`, `total_income_fiat`) are kept
separate per the `models.py:93` comment "original entries only; not net of
reconciliation" — so they don't reflect settlement activity or credit.
Excluded from the balance query: pending entries (flag `!`), voided entries
(tag `voided`). Tested explicitly here so the contract is locked in.
Note: this file does NOT cover post-settlement netting; that's blocked on
issue #33 (settlement leaves both per-user accounts non-zero) and lives in
the settlement test file.
"""
import importlib
from datetime import date
from uuid import uuid4
import pytest
from .helpers import (
approve_entry,
get_all_balances,
get_balance,
list_user_entries,
post_expense,
post_income,
post_receivable,
reject_entry,
)
def _libra_module(submodule: str):
"""Import a libra submodule via whichever path the harness uses (matches
the resolver in conftest.py)."""
for prefix in ("lnbits.extensions.libra", "libra"):
try:
return importlib.import_module(f"{prefix}.{submodule}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(f"libra.{submodule}")
async def _approve_and_refresh(client, wallet, super_user_headers, entry_id):
"""Approve a pending entry then force a fresh Fava read.
Workaround for libra issue #37 — BQL balance reads can lag add_entry
by a few ms. The user-journal endpoint forces a Fava reload.
"""
await approve_entry(
client, super_user_headers=super_user_headers, entry_id=entry_id,
)
await list_user_entries(client, wallet_inkey=wallet.inkey)
# ---------------------------------------------------------------------------
# Single-direction balances
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_pure_expense_balance_is_negative(
client, super_user_headers, configured_user, standard_accounts,
):
"""User submits a single expense → libra owes them → balance < 0 EUR."""
_, wallet = configured_user
entry = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="40.00", currency="EUR",
description=f"Pure expense {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, entry["id"])
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert float(eur) == pytest.approx(-40.0), (
f"expected -40 EUR (libra owes user), got {eur}"
)
@pytest.mark.anyio
async def test_pure_income_balance_is_positive(
client, super_user_headers, configured_user, standard_accounts,
):
"""User submits a single income → user owes libra → balance > 0 EUR.
`/entries/income` records that the user collected money on libra's
behalf, creating an `Assets:Receivable:User-{id}` debit until they
settle by handing the cash over.
"""
_, wallet = configured_user
entry = await post_income(
client,
wallet_inkey=wallet.inkey,
amount="120.00", currency="EUR",
description=f"Pure income {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, entry["id"])
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert float(eur) == pytest.approx(120.0), (
f"expected +120 EUR (user owes libra), got {eur}"
)
# ---------------------------------------------------------------------------
# Mixed direction — the headline scenario
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_mixed_expense_and_income_nets_correctly(
client, super_user_headers, configured_user, standard_accounts,
):
"""User has 50 EUR expense + 120 EUR income (both approved) → net
balance is +70 EUR (user owes libra 70).
This is the user's headline "displayed correctly" scenario — the
Payable and Receivable rows sum into one EUR figure.
"""
_, wallet = configured_user
expense = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="50.00", currency="EUR",
description=f"Coffee {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
income = await post_income(
client,
wallet_inkey=wallet.inkey,
amount="120.00", currency="EUR",
description=f"Cash deposit {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, expense["id"])
await _approve_and_refresh(client, wallet, super_user_headers, income["id"])
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert float(eur) == pytest.approx(70.0), (
f"expected +70 EUR (120 - 50, user-owes-libra), got {eur} from {balance}"
)
@pytest.mark.anyio
async def test_mixed_expense_and_receivable_nets_correctly(
client, super_user_headers, configured_user, standard_accounts,
):
"""Admin-recorded receivable + user-submitted expense should net the
same way as expense + income both push the receivable side."""
user, wallet = configured_user
await post_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount="80.00", currency="EUR",
description=f"Admin debt {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
expense = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="30.00", currency="EUR",
description=f"User expense {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, expense["id"])
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert float(eur) == pytest.approx(50.0), (
f"expected +50 EUR (80 - 30), got {eur} from {balance}"
)
# ---------------------------------------------------------------------------
# Lifetime totals (separate from net balance)
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_lifetime_totals_track_originals_not_net(
client, super_user_headers, configured_user, standard_accounts,
):
"""`total_expenses_fiat` and `total_income_fiat` track originally-entered
amounts, not net obligation see the `models.py:93` invariant. Even
after partial-direction submissions, the totals should equal the gross.
"""
_, wallet = configured_user
expense = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="45.00", currency="EUR",
description=f"e1 {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
income = await post_income(
client,
wallet_inkey=wallet.inkey,
amount="80.00", currency="EUR",
description=f"i1 {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, expense["id"])
await _approve_and_refresh(client, wallet, super_user_headers, income["id"])
balance = await get_balance(client, wallet_inkey=wallet.inkey)
exp_eur = balance.get("total_expenses_fiat", {}).get("EUR", 0)
inc_eur = balance.get("total_income_fiat", {}).get("EUR", 0)
assert float(exp_eur) == pytest.approx(45.0), (
f"total_expenses_fiat should be gross 45, got {exp_eur}"
)
assert float(inc_eur) == pytest.approx(80.0), (
f"total_income_fiat should be gross 80, got {inc_eur}"
)
# ---------------------------------------------------------------------------
# Exclusions — pending and voided
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_pending_entries_excluded_from_balance(
client, super_user_headers, configured_user, standard_accounts,
):
"""Two expenses submitted, only one approved → only the approved one
moves the balance."""
_, wallet = configured_user
approved = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="25.00", currency="EUR",
description=f"approved-only {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
# Submit a second expense but leave it pending.
await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="1000.00", currency="EUR",
description=f"pending-not-counted {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_supplies"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, approved["id"])
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert float(eur) == pytest.approx(-25.0), (
f"only approved expense should count; pending 1000 must be excluded. "
f"got {eur}"
)
@pytest.mark.anyio
async def test_voided_entries_excluded_from_balance(
client, super_user_headers, configured_user, standard_accounts,
):
"""A voided entry stops contributing to the balance the moment it's
rejected verified by submitting then rejecting and confirming the
balance is what it would be without that entry."""
_, wallet = configured_user
keep = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="35.00", currency="EUR",
description=f"keep {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
rejected = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="500.00", currency="EUR",
description=f"will-be-voided {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, keep["id"])
await reject_entry(
client, super_user_headers=super_user_headers, entry_id=rejected["id"],
)
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert float(eur) == pytest.approx(-35.0), (
f"voided 500 must not contribute; only the 35 EUR keeper. got {eur}"
)
# ---------------------------------------------------------------------------
# Admin /balances/all
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_admin_balances_all_includes_users_with_obligations(
client, super_user_headers, configured_user, configured_user_b,
standard_accounts,
):
"""`/balances/all` returns one row per user that has any Payable or
Receivable activity. Two users two rows after both submit + approve.
"""
user_a, wallet_a = configured_user
user_b, wallet_b = configured_user_b
a_entry = await post_expense(
client,
wallet_inkey=wallet_a.inkey,
user_wallet_id=wallet_a.id,
amount="60.00", currency="EUR",
description=f"A-bal {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
b_entry = await post_expense(
client,
wallet_inkey=wallet_b.inkey,
user_wallet_id=wallet_b.id,
amount="90.00", currency="EUR",
description=f"B-bal {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
await _approve_and_refresh(client, wallet_a, super_user_headers, a_entry["id"])
await _approve_and_refresh(client, wallet_b, super_user_headers, b_entry["id"])
rows = await get_all_balances(client, super_user_headers=super_user_headers)
by_id = {r.get("user_id")[:8]: r for r in rows if r.get("user_id")}
assert user_a.id[:8] in by_id, f"user A missing from /balances/all"
assert user_b.id[:8] in by_id, f"user B missing from /balances/all"
a_eur = by_id[user_a.id[:8]].get("fiat_balances", {}).get("EUR")
b_eur = by_id[user_b.id[:8]].get("fiat_balances", {}).get("EUR")
assert float(a_eur) == pytest.approx(-60.0), (
f"user A EUR balance wrong in /balances/all: {a_eur}"
)
assert float(b_eur) == pytest.approx(-90.0), (
f"user B EUR balance wrong in /balances/all: {b_eur}"
)
@pytest.mark.anyio
async def test_non_super_user_cannot_get_all_balances(
client, configured_user,
):
"""`/balances/all` is admin-only — regular user wallet admin-key 403s."""
_, wallet = configured_user
r = await client.get(
"/libra/api/v1/balances/all",
headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert "super" in r.text.lower()
# ---------------------------------------------------------------------------
# Credit balance — libra-#41
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_credit_balance_subtracts_from_net(
client, configured_user,
):
"""A user-credit balance on `Liabilities:Credit:User-X` flows into the
displayed net so the user-facing balance is always honest about what
libra owes them.
`#41` will land the settlement-side overflow logic that writes credit
automatically. This test pre-creates the credit account and posts a
balanced credit-bearing transaction directly via Fava so we can lock
in the BQL-side behaviour (`get_user_balance_bql` includes the Credit
namespace alongside Payable + Receivable) ahead of the settlement
endpoint changes in #14.
"""
user, wallet = configured_user
fava_client_mod = _libra_module("fava_client")
fava = fava_client_mod.get_fava_client()
# Open the per-user credit account in Beancount. The settlement endpoint
# will do this via `get_or_create_user_account` when #14 lands.
credit_account = f"Liabilities:Credit:User-{user.id[:8]}"
await fava.add_account(credit_account, currencies=["EUR", "SATS"])
# Manually post a balanced entry mimicking what the future settlement
# overflow leg looks like in isolation:
# DR Assets:Cash +30 EUR (libra receives cash)
# CR Liabilities:Credit -30 EUR (libra owes user that 30 going forward)
tag = uuid4().hex[:6]
beancount_format = _libra_module("beancount_format")
entry = beancount_format.format_transaction(
date_val=date.today(),
flag="*",
narration=f"Credit-balance test {tag}",
postings=[
{"account": "Assets:Cash", "amount": "30.00 EUR"},
{"account": credit_account, "amount": "-30.00 EUR"},
],
tags=["credit-test"],
links=[f"credit-test-{tag}"],
meta={"user-id": user.id, "source": "test"},
)
await fava.add_entry(entry)
# Force a fresh Fava read before the BQL balance query (libra-#37).
await list_user_entries(client, wallet_inkey=wallet.inkey)
# The user's EUR balance should now read -30 (libra owes user 30 via
# credit). Without the BQL change, this would read 0 because the query
# would skip the Credit namespace entirely.
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert eur is not None, f"missing EUR in fiat_balances: {balance}"
assert float(eur) == pytest.approx(-30.0), (
f"expected -30 EUR (libra owes user via credit), got {eur} from {balance}"
)
# The accounts breakdown should surface the credit row so UIs can render
# it as a distinct line item per #41's display contract. `accounts` (the
# legacy field on UserBalance) stays empty for back-compat; the new
# `account_balances` field carries the BQL per-account breakdown.
account_balances = balance.get("account_balances", [])
credit_rows = [
a for a in account_balances if "Credit" in (a.get("account") or "")
]
assert credit_rows, (
f"credit account missing from breakdown — UI can't render 'You have "
f"30 EUR credit' line item. account_balances: {account_balances}"
)

View file

@ -0,0 +1,219 @@
"""Admin-side journal entry endpoints — receivable and revenue.
- `POST /libra/api/v1/entries/receivable` admin records that a user owes
libra. Lands as a pending (`!`) entry, balance untouched until approve.
- `POST /libra/api/v1/entries/revenue` admin records that libra received
a payment unrelated to any user. Lands as a cleared (`*`) entry, no
approval needed.
Auth gate covered too: a regular user's wallet admin-key passes
`require_admin_key` but fails the super-user identity check in libra's own
`require_super_user`, so the endpoint returns 403.
"""
from uuid import uuid4
import pytest
from .helpers import (
get_balance,
list_user_entries,
post_receivable,
post_revenue,
)
@pytest.mark.anyio
async def test_admin_records_receivable_lands_cleared(
client, super_user_headers, configured_user, standard_accounts,
):
"""Admin posts a receivable for a user — the Beancount entry is written
with the cleared `*` flag immediately (not pending). The user's balance
reflects the debt without an approve step.
Note: `JournalEntry.flag` in the API response is misleading it's a
leftover of the legacy model and reports PENDING, but the entry in
Beancount is written as `*`. The on-disk reality is what affects the
balance, so that's what we assert.
"""
user, wallet = configured_user
response = await post_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount="200.00",
currency="EUR",
description=f"December rent share {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
assert response.get("id"), f"expected id in response, got {response}"
# Force a fresh Fava read before checking balance — Fava lazily reloads
# the .beancount file and a balance call right after add_entry can hit
# a stale view.
await list_user_entries(client, wallet_inkey=wallet.inkey)
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert eur is not None, f"expected EUR in fiat_balances, got {balance}"
assert float(eur) == pytest.approx(200.0), (
f"expected +200 EUR (user-owes-libra) after receivable, got {eur}"
)
@pytest.mark.anyio
async def test_receivable_visible_in_target_users_journal(
client, super_user_headers, configured_user, standard_accounts,
):
"""The receivable shows up in the *debtor* user's journal listing
(not just in the admin view)."""
user, wallet = configured_user
tag = uuid4().hex[:6]
await post_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount="75.00",
currency="EUR",
description=f"Workshop fee {tag}",
revenue_account=standard_accounts["revenue_fees"]["name"],
)
listing = await list_user_entries(client, wallet_inkey=wallet.inkey)
descriptions = [e.get("description") or "" for e in listing.get("entries", [])]
assert any(tag in d for d in descriptions), (
f"receivable missing from debtor's journal: {descriptions}"
)
@pytest.mark.anyio
async def test_admin_records_revenue_clears_immediately(
client, super_user_headers, standard_accounts,
):
"""Revenue (libra received money, no user debt) is cleared on creation —
no admin approval step."""
response = await post_revenue(
client,
super_user_headers=super_user_headers,
amount="500.00",
currency="EUR",
description=f"Workshop fees collected {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_fees"]["name"],
payment_method_account="Assets:Cash",
)
assert response.get("id"), f"expected id in response, got {response}"
# Cleared on creation — flag is `*`, no approve_entry call needed.
@pytest.mark.anyio
async def test_non_super_user_cannot_post_receivable(
client, configured_user, standard_accounts,
):
"""A regular user's wallet admin key passes `require_admin_key` but
fails libra's super-user identity check. Returns 403."""
user, wallet = configured_user
admin_key_headers = {"X-Api-Key": wallet.adminkey, "Content-type": "application/json"}
r = await client.post(
"/libra/api/v1/entries/receivable",
headers=admin_key_headers,
json={
"user_id": user.id,
"amount": "10.00",
"currency": "EUR",
"description": "Should be denied",
"revenue_account": standard_accounts["revenue_rent"]["name"],
},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert "super" in r.text.lower(), (
f"expected super-user error message, got {r.text!r}"
)
@pytest.mark.anyio
async def test_non_super_user_cannot_post_revenue(
client, configured_user, standard_accounts,
):
"""Same super-user gate covers the revenue endpoint."""
_, wallet = configured_user
admin_key_headers = {"X-Api-Key": wallet.adminkey, "Content-type": "application/json"}
r = await client.post(
"/libra/api/v1/entries/revenue",
headers=admin_key_headers,
json={
"amount": "10.00",
"currency": "EUR",
"description": "Should be denied",
"revenue_account": standard_accounts["revenue_fees"]["name"],
"payment_method_account": "Assets:Cash",
},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert "super" in r.text.lower()
@pytest.mark.anyio
async def test_receivable_unknown_revenue_account_returns_404(
client, super_user_headers, configured_user,
):
"""An admin posting against a non-existent revenue account gets 404."""
user, _ = configured_user
r = await client.post(
"/libra/api/v1/entries/receivable",
headers=super_user_headers,
json={
"user_id": user.id,
"amount": "10.00",
"currency": "EUR",
"description": "Bad account",
"revenue_account": f"Income:Test:DoesNotExist-{uuid4().hex[:6]}",
},
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
assert "not found" in r.text.lower()
@pytest.mark.anyio
async def test_receivable_unknown_currency_returns_400(
client, super_user_headers, configured_user, standard_accounts,
):
"""Currency validation hits before account lookups."""
user, _ = configured_user
r = await client.post(
"/libra/api/v1/entries/receivable",
headers=super_user_headers,
json={
"user_id": user.id,
"amount": "10.00",
"currency": "XYZ",
"description": "Bogus currency",
"revenue_account": standard_accounts["revenue_rent"]["name"],
},
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "currency" in r.text.lower() or "xyz" in r.text.lower()
@pytest.mark.anyio
async def test_revenue_unknown_payment_account_returns_404(
client, super_user_headers, standard_accounts,
):
"""Revenue endpoint validates BOTH accounts; the payment-method one too."""
r = await client.post(
"/libra/api/v1/entries/revenue",
headers=super_user_headers,
json={
"amount": "10.00",
"currency": "EUR",
"description": "Bad payment account",
"revenue_account": standard_accounts["revenue_fees"]["name"],
"payment_method_account": f"Assets:DoesNotExist-{uuid4().hex[:6]}",
},
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
assert "not found" in r.text.lower()

View file

@ -0,0 +1,211 @@
"""User-side expense submission flow — `POST /libra/api/v1/entries/expense`.
Covers:
- Submission lands as a pending entry, visible to the user, doesn't move
the cleared-only balance.
- Cross-user isolation user B can't see user A's entries.
- Permission gating, currency validation, missing user-wallet setup.
- Multiple submissions accumulate in the user journal listing.
Settlement, approval, and balance-after-approval are exercised in
`test_smoke.py` (one canonical path) and `test_balances_api.py` (the mixed
income+expense display scenario the user named).
"""
from uuid import uuid4
import pytest
from .helpers import (
create_account,
get_balance,
list_user_entries,
post_expense,
)
@pytest.mark.anyio
async def test_expense_creates_pending_entry_visible_in_user_journal(
client, configured_user, standard_accounts,
):
"""Submitting an expense creates a pending (`!`) entry the user can see
immediately. The cleared-only balance query is unchanged because pending
entries are excluded."""
_, wallet = configured_user
response = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="25.00",
currency="EUR",
description="Test groceries",
expense_account=standard_accounts["expense_food"]["name"],
)
assert response.get("id"), f"expected id in response, got {response}"
listing = await list_user_entries(client, wallet_inkey=wallet.inkey)
entries = listing.get("entries", [])
assert any(
"Test groceries" in (e.get("description") or "") for e in entries
), f"submitted expense missing from /entries/user: {entries}"
bal = await get_balance(client, wallet_inkey=wallet.inkey)
assert not bal.get("fiat_balances"), (
f"pending entry should not affect cleared balance, got {bal}"
)
@pytest.mark.anyio
async def test_user_cannot_see_other_users_entries(
client, configured_user, configured_user_b, standard_accounts,
):
"""User A submits an expense; user B's `/entries/user` listing is
scoped to B and never references A's user-id account fragment."""
user_a, wallet_a = configured_user
_, wallet_b = configured_user_b
await post_expense(
client,
wallet_inkey=wallet_a.inkey,
user_wallet_id=wallet_a.id,
amount="40.00",
currency="EUR",
description=f"A-private-{uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
listing_b = await list_user_entries(client, wallet_inkey=wallet_b.inkey)
a_short = user_a.id[:8]
for entry in listing_b.get("entries", []):
for posting in entry.get("postings", []):
assert a_short not in posting.get("account", ""), (
f"user B's listing leaked user A's account: {posting}"
)
@pytest.mark.anyio
async def test_expense_without_permission_returns_403(
client, super_user_headers, configured_user,
):
"""Submitting to an expense account the user has no `submit_expense`
permission on returns 403 with a permission-error detail."""
_, wallet = configured_user
# Fresh expense account that no permission was granted on.
new_account = await create_account(
client,
super_user_headers=super_user_headers,
name=f"Expenses:Test:Unguarded-{uuid4().hex[:6]}",
account_type="expense",
)
r = await client.post(
"/libra/api/v1/entries/expense",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={
"description": "Should be denied",
"amount": "10.00",
"currency": "EUR",
"expense_account": new_account["name"],
"user_wallet": wallet.id,
"is_equity": False,
},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert "permission" in r.text.lower(), (
f"expected permission error message, got {r.text!r}"
)
@pytest.mark.anyio
async def test_expense_with_unknown_currency_returns_400(
client, configured_user, standard_accounts,
):
"""An unsupported currency is rejected with 400 before any Fava call."""
_, wallet = configured_user
r = await client.post(
"/libra/api/v1/entries/expense",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={
"description": "Unknown currency",
"amount": "10.00",
"currency": "XYZ",
"expense_account": standard_accounts["expense_food"]["name"],
"user_wallet": wallet.id,
"is_equity": False,
},
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "currency" in r.text.lower(), (
f"expected currency error message, got {r.text!r}"
)
@pytest.mark.anyio
async def test_expense_without_user_wallet_configured_returns_400(
client, libra_user, libra_wallet, standard_accounts, # noqa: ARG001 (libra_wallet ensures session-level setup)
):
"""A user whose own libra wallet isn't configured can't submit expenses.
`libra_user` (vs `configured_user`) skips the `PUT /user/wallet` step
on purpose so the precondition fires.
"""
_, wallet = libra_user
r = await client.post(
"/libra/api/v1/entries/expense",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={
"description": "Missing user wallet setup",
"amount": "10.00",
"currency": "EUR",
"expense_account": standard_accounts["expense_food"]["name"],
"user_wallet": wallet.id,
"is_equity": False,
},
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "wallet" in r.text.lower(), (
f"expected wallet-config error, got {r.text!r}"
)
@pytest.mark.anyio
async def test_multiple_expenses_accumulate_in_user_journal(
client, configured_user, standard_accounts,
):
"""Each submission shows up in `/entries/user`; the listing's `total`
grows by exactly the number of submissions."""
_, wallet = configured_user
initial = await list_user_entries(client, wallet_inkey=wallet.inkey)
initial_total = initial.get("total", 0)
tag = uuid4().hex[:6]
descriptions = [f"Coffee-{tag}", f"Bread-{tag}", f"Vegetables-{tag}"]
for description in descriptions:
await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="7.50",
currency="EUR",
description=description,
expense_account=standard_accounts["expense_food"]["name"],
)
final = await list_user_entries(client, wallet_inkey=wallet.inkey)
final_total = final.get("total", 0)
assert final_total - initial_total == len(descriptions), (
f"expected total to grow by {len(descriptions)}, "
f"went from {initial_total} to {final_total}"
)
# Libra appends " (<amount> <currency>)" to entry descriptions, so check
# substring rather than exact match.
final_descs = [e.get("description") or "" for e in final.get("entries", [])]
for description in descriptions:
assert any(description in d for d in final_descs), (
f"missing {description} from journal listing: {final_descs}"
)

View file

@ -0,0 +1,168 @@
"""Entry identity resolution — the canonical id must survive a user reference.
Regression coverage for the production bug where a pending income entry
created with a `reference` (e.g. an invoice number like "42-144") could
not be approved: the admin UI's pending list resolved the entry id by
parsing links for a `libra-` prefix, but reference-bearing entries carry
typed links (`inc-/exp-/rcv-{id}`) plus the reference as its own link
no `libra-` link. The id surfaced as the literal string "unknown" and
`POST /entries/unknown/approve` 404'd.
The fix makes the `entry-id` transaction metadata the single source of
truth (list, approve, and reject endpoints), with link parsing kept only
for pre-metadata ledger history. These tests pin that contract:
- pending list returns the real id for reference-bearing entries
- approve/reject resolve that id end-to-end
- the user reference round-trips as `reference`, never as a system link
"""
from uuid import uuid4
import pytest
from .helpers import (
approve_entry,
list_pending_entries,
list_user_entries,
post_expense,
post_income,
reject_entry,
)
@pytest.mark.anyio
async def test_pending_income_with_reference_resolves_real_id(
client, super_user_headers, configured_user, standard_accounts,
):
"""The production repro: income + reference must list with its real
id (not 'unknown') and approve successfully."""
_, wallet = configured_user
marker = f"Membership dues {uuid4().hex[:6]}"
posted = await post_income(
client,
wallet_inkey=wallet.inkey,
amount="700.00", currency="EUR",
description=marker,
revenue_account=standard_accounts["revenue_rent"]["name"],
reference="42-144",
)
pending = await list_pending_entries(
client, super_user_headers=super_user_headers,
)
entry = next(
(e for e in pending if marker in (e.get("description") or "")), None,
)
assert entry is not None, f"income entry not in pending list: {pending}"
assert entry["id"] == posted["id"], (
f"pending list must surface the canonical entry id, "
f"got {entry['id']!r} (expected {posted['id']!r})"
)
assert entry["id"] != "unknown"
# The id from the listing must drive approval end-to-end.
result = await approve_entry(
client, super_user_headers=super_user_headers, entry_id=entry["id"],
)
assert result.get("entry_id") == posted["id"]
@pytest.mark.anyio
async def test_pending_expense_with_reference_resolves_real_id_and_rejects(
client, super_user_headers, configured_user, standard_accounts,
):
"""Same contract on the expense path, exercised through reject."""
_, wallet = configured_user
marker = f"Receipted groceries {uuid4().hex[:6]}"
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="36.93", currency="EUR",
description=marker,
expense_account=standard_accounts["expense_food"]["name"],
reference="RECEIPT/2026-06-12",
)
pending = await list_pending_entries(
client, super_user_headers=super_user_headers,
)
entry = next(
(e for e in pending if marker in (e.get("description") or "")), None,
)
assert entry is not None, f"expense entry not in pending list: {pending}"
assert entry["id"] == posted["id"]
result = await reject_entry(
client, super_user_headers=super_user_headers, entry_id=entry["id"],
)
assert result.get("entry_id") == posted["id"]
@pytest.mark.anyio
async def test_reference_round_trips_in_user_journal(
client, configured_user, standard_accounts,
):
"""The user journal must report the user's reference, not a system
link (typed inc-/exp- links used to leak into the reference field)."""
_, wallet = configured_user
marker = f"Referenced expense {uuid4().hex[:6]}"
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="12.00", currency="EUR",
description=marker,
expense_account=standard_accounts["expense_food"]["name"],
reference="INV-7731",
)
assert posted.get("reference") == "INV-7731"
listing = await list_user_entries(client, wallet_inkey=wallet.inkey)
entry = next(
(
e for e in listing.get("entries", [])
if marker in (e.get("description") or "")
),
None,
)
assert entry is not None
assert entry["id"] == posted["id"]
assert entry.get("reference") == "INV-7731", (
f"reference field must carry the user's reference, "
f"got {entry.get('reference')!r}"
)
@pytest.mark.anyio
async def test_entry_without_reference_still_resolves(
client, super_user_headers, configured_user, standard_accounts,
):
"""No-reference entries keep working (the case that always worked)."""
_, wallet = configured_user
marker = f"Plain income {uuid4().hex[:6]}"
posted = await post_income(
client,
wallet_inkey=wallet.inkey,
amount="55.00", currency="EUR",
description=marker,
revenue_account=standard_accounts["revenue_rent"]["name"],
)
pending = await list_pending_entries(
client, super_user_headers=super_user_headers,
)
entry = next(
(e for e in pending if marker in (e.get("description") or "")), None,
)
assert entry is not None
assert entry["id"] == posted["id"]
result = await approve_entry(
client, super_user_headers=super_user_headers, entry_id=entry["id"],
)
assert result.get("entry_id") == posted["id"]

205
tests/test_lightning_api.py Normal file
View file

@ -0,0 +1,205 @@
"""Lightning payment flow — `POST /generate-payment-invoice` and
`POST /record-payment`.
- User has a balance owed to libra user generates an invoice on the libra
wallet user pays it `/record-payment` records the settlement entry.
## Coverage status
This file covers auth gates and error paths that don't require an active
Lightning backend. Tests that actually need invoice generation are skipped
because:
- The default `VoidWallet` 500s on any invoice operation.
- Switching to `FakeWallet` (via `settings.lnbits_backend_wallet_class`)
DOES enable invoice generation, but the LifespanManager teardown then
hangs indefinitely under anyio's TestRunner — some Lightning-side
background task doesn't unwind cleanly. Investigation deferred; the
auth gates + 404/400 error paths are what we can lock in for now.
The skipped tests carry full implementations so flipping them back on is
a one-line change once the teardown issue is resolved (or once we move to
a subprocess-based runner for the LN file).
"""
from uuid import uuid4
import pytest
from .helpers import (
list_user_entries,
post_receivable,
)
NEEDS_LIGHTNING_BACKEND = pytest.mark.skip(
reason="Tracked by libra/issues/40 — VoidWallet 500s, FakeWallet hangs the "
"LifespanManager teardown under anyio's TestRunner. Flip when resolved."
)
async def _setup_receivable_balance(
client, super_user_headers, configured_user, standard_accounts,
amount="100.00",
):
"""Helper: create + (auto-cleared) receivable so the user has a balance
owed to libra. Returns the (user, wallet) pair."""
user, wallet = configured_user
await post_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount=amount, currency="EUR",
description=f"Setup debt {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
# Force a Fava reload before downstream BQL balance reads (see #37).
await list_user_entries(client, wallet_inkey=wallet.inkey)
return user, wallet
# ---------------------------------------------------------------------------
# /generate-payment-invoice
# ---------------------------------------------------------------------------
@NEEDS_LIGHTNING_BACKEND
@pytest.mark.anyio
async def test_user_can_generate_invoice_for_own_balance(
client, super_user_headers, configured_user, standard_accounts,
):
"""User with a receivable generates an invoice on the libra wallet.
Response carries the bolt11 string and the libra wallet's inkey for
the client to poll payment status."""
_, wallet = await _setup_receivable_balance(
client, super_user_headers, configured_user, standard_accounts,
)
r = await client.post(
"/libra/api/v1/generate-payment-invoice",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={"amount": 50_000}, # 50k sats partial settlement
)
assert r.status_code == 200, f"generate-invoice: {r.status_code} {r.text}"
payload = r.json()
assert payload.get("payment_hash"), f"missing payment_hash: {payload}"
assert payload.get("payment_request"), f"missing bolt11 payment_request: {payload}"
assert payload.get("amount") == 50_000
assert payload.get("check_wallet_key"), f"missing check_wallet_key: {payload}"
@NEEDS_LIGHTNING_BACKEND
@pytest.mark.anyio
async def test_super_user_can_generate_invoice_for_another_user(
client, super_user_headers, libra_wallet, configured_user, standard_accounts,
):
"""Admin generating an invoice on behalf of a user — uses the libra
wallet's admin key + body `user_id`. The endpoint actually requires
`wallet.wallet.user == super_user` (which is the libra wallet owner).
Generate-invoice is `require_invoice_key`-gated so we pass the libra
wallet's invoice key, and the user_id field opts into "for that user".
"""
user, _ = await _setup_receivable_balance(
client, super_user_headers, configured_user, standard_accounts,
)
r = await client.post(
"/libra/api/v1/generate-payment-invoice",
headers={"X-Api-Key": libra_wallet.inkey, "Content-type": "application/json"},
json={"amount": 30_000, "user_id": user.id},
)
assert r.status_code == 200, f"admin generate-invoice: {r.status_code} {r.text}"
assert r.json().get("payment_request"), "admin-generated invoice missing bolt11"
@pytest.mark.anyio
async def test_non_super_user_cannot_generate_invoice_for_another_user(
client, super_user_headers, configured_user, configured_user_b,
standard_accounts,
):
"""A regular user cannot pass `user_id` and have libra generate an
invoice on someone else's behalf — 403."""
user_a, _ = await _setup_receivable_balance(
client, super_user_headers, configured_user, standard_accounts,
)
_, wallet_b = configured_user_b
r = await client.post(
"/libra/api/v1/generate-payment-invoice",
headers={"X-Api-Key": wallet_b.inkey, "Content-type": "application/json"},
json={"amount": 10_000, "user_id": user_a.id},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_generate_invoice_without_auth_returns_401(client):
"""Invoice-key auth required — no header → 401."""
r = await client.post(
"/libra/api/v1/generate-payment-invoice",
json={"amount": 10_000},
)
assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}"
# ---------------------------------------------------------------------------
# /record-payment
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_record_payment_unknown_hash_returns_404(
client, configured_user,
):
"""Recording a payment hash that doesn't correspond to a real payment
in LNbits returns 404."""
_, wallet = configured_user
r = await client.post(
"/libra/api/v1/record-payment",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={"payment_hash": "0" * 64},
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
assert "payment not found" in r.text.lower() or "payment" in r.text.lower()
@NEEDS_LIGHTNING_BACKEND
@pytest.mark.anyio
async def test_record_payment_pending_invoice_returns_400(
client, super_user_headers, configured_user, standard_accounts,
):
"""A freshly-generated invoice that hasn't been paid yet is pending —
`/record-payment` must reject it with 400 rather than silently
recording a non-existent settlement."""
_, wallet = await _setup_receivable_balance(
client, super_user_headers, configured_user, standard_accounts,
)
# Generate an invoice on the libra wallet.
gen = await client.post(
"/libra/api/v1/generate-payment-invoice",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={"amount": 15_000},
)
assert gen.status_code == 200
payment_hash = gen.json()["payment_hash"]
# Try to record it before any payment lands.
r = await client.post(
"/libra/api/v1/record-payment",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={"payment_hash": payment_hash},
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "not yet settled" in r.text.lower() or "pending" in r.text.lower(), (
f"expected pending/settled message, got {r.text!r}"
)
@pytest.mark.anyio
async def test_record_payment_without_auth_returns_401(client):
r = await client.post(
"/libra/api/v1/record-payment",
json={"payment_hash": "abc"},
)
assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}"

View file

@ -0,0 +1,307 @@
"""Manual payment request flow — user asks for libra to pay them via a
non-Lightning route (cash, bank, etc.); admin approves or rejects.
Endpoints:
- `POST /libra/api/v1/manual-payment-request` (invoice key, user)
- `GET /libra/api/v1/manual-payment-requests` (invoice key, own only)
- `GET /libra/api/v1/manual-payment-requests/all` (super user, all)
- `POST /libra/api/v1/manual-payment-requests/{id}/approve` (super user)
- `POST /libra/api/v1/manual-payment-requests/{id}/reject` (super user)
The amount in the request body is in **satoshis** (no fiat conversion at this
endpoint `CreateManualPaymentRequest` has `amount: int`).
Approve creates a Beancount payment entry:
DR Liabilities:Payable:User-{id} (zeroes libra's debt to the user)
CR Assets:Bitcoin:Lightning (cash leaves libra)
"""
from uuid import uuid4
import pytest
from .helpers import (
approve_manual_payment_request,
reject_manual_payment_request,
submit_manual_payment_request,
)
# ---------------------------------------------------------------------------
# User-side submission
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_user_can_submit_manual_payment_request(
client, configured_user,
):
"""Submission returns 200 with a pending request and the user's id."""
user, wallet = configured_user
desc = f"Coffee reimbursement {uuid4().hex[:6]}"
result = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=50_000,
description=desc,
)
assert result.get("id"), f"missing id: {result}"
assert result.get("user_id") == user.id
assert result.get("amount") == 50_000
assert result.get("description") == desc
assert result.get("status") == "pending"
@pytest.mark.anyio
async def test_user_lists_own_manual_payment_requests(
client, configured_user,
):
"""The user-side listing returns the requests this user submitted."""
_, wallet = configured_user
tag = uuid4().hex[:6]
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=12_000,
description=f"list-test {tag}",
)
r = await client.get(
"/libra/api/v1/manual-payment-requests",
headers={"X-Api-Key": wallet.inkey},
)
assert r.status_code == 200, f"list: {r.status_code} {r.text}"
ids = [req.get("id") for req in r.json()]
assert submitted["id"] in ids, f"submitted request missing from listing: {ids}"
@pytest.mark.anyio
async def test_user_cannot_see_another_users_manual_payment_requests(
client, configured_user, configured_user_b,
):
"""User-side listing is scoped to the calling user, not all requests."""
user_a, wallet_a = configured_user
_, wallet_b = configured_user_b
submitted_a = await submit_manual_payment_request(
client,
wallet_inkey=wallet_a.inkey,
amount_sats=8_000,
description=f"A-private {uuid4().hex[:6]}",
)
r = await client.get(
"/libra/api/v1/manual-payment-requests",
headers={"X-Api-Key": wallet_b.inkey},
)
assert r.status_code == 200
user_ids = {req.get("user_id") for req in r.json()}
ids = [req.get("id") for req in r.json()]
assert submitted_a["id"] not in ids, (
f"user B saw user A's request: {submitted_a['id']} in {ids}"
)
assert user_a.id not in user_ids, (
f"user B's listing contained user A's id: {user_ids}"
)
# ---------------------------------------------------------------------------
# Admin listing
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_admin_can_list_all_manual_payment_requests(
client, super_user_headers, configured_user, configured_user_b,
):
"""The admin listing returns requests from any user."""
_, wallet_a = configured_user
_, wallet_b = configured_user_b
a_req = await submit_manual_payment_request(
client,
wallet_inkey=wallet_a.inkey,
amount_sats=10_000,
description=f"A {uuid4().hex[:6]}",
)
b_req = await submit_manual_payment_request(
client,
wallet_inkey=wallet_b.inkey,
amount_sats=20_000,
description=f"B {uuid4().hex[:6]}",
)
r = await client.get(
"/libra/api/v1/manual-payment-requests/all",
headers=super_user_headers,
)
assert r.status_code == 200, f"admin list: {r.status_code} {r.text}"
ids = [req.get("id") for req in r.json()]
assert a_req["id"] in ids and b_req["id"] in ids, (
f"admin list missing entries: ids={ids}"
)
@pytest.mark.anyio
async def test_admin_listing_status_filter(
client, super_user_headers, configured_user,
):
"""`?status=pending` returns only the pending requests."""
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=5_000,
description=f"pending-filter {uuid4().hex[:6]}",
)
r = await client.get(
"/libra/api/v1/manual-payment-requests/all?status=pending",
headers=super_user_headers,
)
assert r.status_code == 200, f"filtered list: {r.status_code} {r.text}"
statuses = {req.get("status") for req in r.json()}
assert statuses == {"pending"}, f"non-pending rows in filtered list: {statuses}"
assert submitted["id"] in [req.get("id") for req in r.json()]
@pytest.mark.anyio
async def test_non_super_user_cannot_list_all_requests(
client, configured_user,
):
"""Wallet admin-key of a non-super user fails the super-user check."""
_, wallet = configured_user
r = await client.get(
"/libra/api/v1/manual-payment-requests/all",
headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert "super" in r.text.lower()
# ---------------------------------------------------------------------------
# Approve / reject
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_admin_can_reject_manual_payment_request(
client, super_user_headers, configured_user,
):
"""Reject flips status to 'rejected' and doesn't touch Beancount."""
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=3_500,
description=f"reject me {uuid4().hex[:6]}",
)
result = await reject_manual_payment_request(
client, super_user_headers=super_user_headers, request_id=submitted["id"],
)
assert result.get("status") == "rejected"
@pytest.mark.anyio
async def test_rejecting_already_rejected_returns_400(
client, super_user_headers, configured_user,
):
"""The endpoint guards against double-decisions."""
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=4_000,
description=f"double reject {uuid4().hex[:6]}",
)
await reject_manual_payment_request(
client, super_user_headers=super_user_headers, request_id=submitted["id"],
)
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/reject",
headers=super_user_headers,
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "reject" in r.text.lower()
@pytest.mark.anyio
async def test_approve_unknown_request_returns_404(
client, super_user_headers,
):
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{uuid4().hex[:16]}/approve",
headers=super_user_headers,
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_non_super_user_cannot_approve(
client, configured_user,
):
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=2_000,
description=f"no approve for you {uuid4().hex[:6]}",
)
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/approve",
headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_admin_can_approve_manual_payment_request(
client, super_user_headers, configured_user, standard_accounts,
# noqa: ARG001 (standard_accounts ensures Assets:Bitcoin:Lightning exists)
):
"""Approve creates a Beancount payment entry and flips status to
'approved'. Requires `Assets:Bitcoin:Lightning` to exist in libra's
local DB (provided by the `standard_accounts` fixture)."""
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=6_000,
description=f"approve me {uuid4().hex[:6]}",
)
result = await approve_manual_payment_request(
client, super_user_headers=super_user_headers, request_id=submitted["id"],
)
assert result.get("status") == "approved"
assert result.get("id") == submitted["id"]
@pytest.mark.anyio
async def test_approving_already_approved_returns_400(
client, super_user_headers, configured_user, standard_accounts,
):
"""Idempotency guard: second approve on the same request is rejected
explicitly rather than producing a duplicate Beancount entry."""
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=7_500,
description=f"approve once {uuid4().hex[:6]}",
)
await approve_manual_payment_request(
client, super_user_headers=super_user_headers, request_id=submitted["id"],
)
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/approve",
headers=super_user_headers,
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "approve" in r.text.lower()

View file

@ -0,0 +1,294 @@
"""Balance assertion CRUD + reconciliation summary endpoints.
Endpoints:
- `POST /libra/api/v1/assertions` create + check
- `GET /libra/api/v1/assertions` list with filters
- `GET /libra/api/v1/assertions/{id}` fetch one
- `POST /libra/api/v1/assertions/{id}/check` re-check
- `DELETE /libra/api/v1/assertions/{id}` remove
All `require_super_user` (libra-level, wallet admin-key).
The create endpoint is hybrid: it posts a Beancount `balance` directive via
Fava (source of truth), persists the assertion metadata in libra's DB, and
re-checks immediately. On mismatch it returns 409 with the diff payload.
"""
from uuid import uuid4
import pytest
# Tests that try to actually create + check an assertion all hit issue #39:
# `format_balance` returns a Beancount source string but `fava.add_entry`
# expects a dict, so Fava 500s on every assertion-create call. The contract
# violation is on libra's side; mark these strict-xfail so they go green
# automatically once #39 lands and the format_balance return shape is fixed.
ASSERTION_CREATE_BROKEN = pytest.mark.xfail(
reason="libra/issues/39 — POST /assertions submits a Beancount source string "
"to Fava's JSON API and 500s. Drop this marker when the format_balance "
"return type is changed to a dict.",
strict=True,
)
# ---------------------------------------------------------------------------
# helpers (local — assertion endpoints don't have wrapper helpers yet)
# ---------------------------------------------------------------------------
async def _create_assertion(
client, *, super_user_headers, account_id, expected_sats,
tolerance_sats=0, fiat_currency=None, expected_fiat=None,
):
body = {
"account_id": account_id,
"expected_balance_sats": expected_sats,
"tolerance_sats": tolerance_sats,
}
if fiat_currency:
body["fiat_currency"] = fiat_currency
body["expected_balance_fiat"] = str(expected_fiat) if expected_fiat is not None else "0"
return await client.post(
"/libra/api/v1/assertions", headers=super_user_headers, json=body,
)
# ---------------------------------------------------------------------------
# tests
# ---------------------------------------------------------------------------
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_assertion_against_empty_account_passes(
client, super_user_headers, standard_accounts,
):
"""An asset account with no postings has a 0 balance — asserting 0
should pass and the resulting assertion has status='passed'."""
r = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=standard_accounts["assets_cash"]["id"],
expected_sats=0,
)
assert r.status_code == 200, f"expected 200, got {r.status_code}: {r.text}"
body = r.json()
assert body.get("status") == "passed", (
f"expected status='passed' for 0=0, got {body.get('status')} body={body}"
)
assert body.get("difference_sats", 0) == 0
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_assertion_with_wrong_balance_returns_409(
client, super_user_headers, standard_accounts,
):
"""When the actual balance doesn't match expected, the create endpoint
returns 409 Conflict with the diff payload Beancount validates it
server-side after the directive lands."""
r = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=standard_accounts["assets_cash"]["id"],
expected_sats=999_999, # wildly wrong for empty account
)
assert r.status_code == 409, f"expected 409, got {r.status_code}: {r.text}"
# 409 body should expose the diff so a UI can render the gap.
detail = r.json().get("detail")
assert isinstance(detail, dict), f"expected structured detail, got {detail!r}"
assert detail.get("expected_sats") == 999_999
assert detail.get("actual_sats") == 0
assert detail.get("difference_sats") == 999_999 or detail.get("difference_sats") == -999_999
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_assertion_with_tolerance_accepts_small_diff(
client, super_user_headers, standard_accounts,
):
"""A tolerance of N sats lets actual-vs-expected diverge by ≤N."""
r = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=standard_accounts["assets_cash"]["id"],
expected_sats=50,
tolerance_sats=100, # actual=0, expected=50, diff=50, tolerance=100 → passes
)
assert r.status_code == 200, f"expected 200 within tolerance, got {r.status_code}: {r.text}"
assert r.json().get("status") == "passed"
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_list_assertions_returns_created(
client, super_user_headers, standard_accounts,
):
"""Newly created assertions show up in the list filtered by account."""
account_id = standard_accounts["assets_cash"]["id"]
create = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=account_id,
expected_sats=0,
)
assert create.status_code == 200
assertion_id = create.json()["id"]
r = await client.get(
f"/libra/api/v1/assertions?account_id={account_id}",
headers=super_user_headers,
)
assert r.status_code == 200, f"list assertions: {r.status_code} {r.text}"
ids = [a.get("id") for a in r.json()]
assert assertion_id in ids, f"created assertion {assertion_id} missing from list {ids}"
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_get_assertion_by_id(
client, super_user_headers, standard_accounts,
):
create = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=standard_accounts["assets_cash"]["id"],
expected_sats=0,
)
assert create.status_code == 200
assertion_id = create.json()["id"]
r = await client.get(
f"/libra/api/v1/assertions/{assertion_id}",
headers=super_user_headers,
)
assert r.status_code == 200, f"get assertion: {r.status_code} {r.text}"
assert r.json().get("id") == assertion_id
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_recheck_assertion_via_check_endpoint(
client, super_user_headers, standard_accounts,
):
"""`POST /assertions/{id}/check` re-evaluates and returns the updated
assertion record. Idempotent against a stable ledger state."""
create = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=standard_accounts["assets_cash"]["id"],
expected_sats=0,
)
assertion_id = create.json()["id"]
r = await client.post(
f"/libra/api/v1/assertions/{assertion_id}/check",
headers=super_user_headers,
)
assert r.status_code == 200, f"recheck: {r.status_code} {r.text}"
assert r.json().get("status") == "passed"
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_delete_assertion_removes_it(
client, super_user_headers, standard_accounts,
):
create = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=standard_accounts["assets_cash"]["id"],
expected_sats=0,
)
assertion_id = create.json()["id"]
r = await client.delete(
f"/libra/api/v1/assertions/{assertion_id}",
headers=super_user_headers,
)
assert r.status_code in (200, 204), f"delete: {r.status_code} {r.text}"
# Subsequent GET should 404.
r = await client.get(
f"/libra/api/v1/assertions/{assertion_id}",
headers=super_user_headers,
)
assert r.status_code == 404, f"expected 404 after delete, got {r.status_code}"
@pytest.mark.anyio
async def test_assertion_unknown_account_returns_404(
client, super_user_headers,
):
"""Account-not-found check happens before any Beancount write."""
r = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=f"nonexistent-{uuid4().hex[:6]}",
expected_sats=0,
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_non_super_user_cannot_create_assertion(
client, configured_user, standard_accounts,
):
"""Wallet admin-key of a regular user fails the super-user identity
check 403."""
_, wallet = configured_user
r = await client.post(
"/libra/api/v1/assertions",
headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"},
json={
"account_id": standard_accounts["assets_cash"]["id"],
"expected_balance_sats": 0,
},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert "super" in r.text.lower()
@pytest.mark.anyio
async def test_list_assertions_invalid_status_returns_400(
client, super_user_headers,
):
"""Status filter is validated against the AssertionStatus enum."""
r = await client.get(
"/libra/api/v1/assertions?status=not_a_status",
headers=super_user_headers,
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "status" in r.text.lower()
@pytest.mark.anyio
async def test_reconciliation_summary_endpoint(client, super_user_headers):
"""`GET /reconciliation/summary` responds 200 and returns a structured
payload even when no assertions exist. Smoke-shape only exact counts
depend on ledger history.
Doesn't pre-create an assertion (#39 blocks that path); the summary
endpoint should still serve a default empty shape.
"""
r = await client.get(
"/libra/api/v1/reconciliation/summary",
headers=super_user_headers,
)
assert r.status_code == 200, f"reconciliation summary: {r.status_code} {r.text}"
payload = r.json()
assert isinstance(payload, dict), f"expected dict, got {type(payload)}"
@pytest.mark.anyio
async def test_daily_reconciliation_task_runs(
client, super_user_headers,
):
"""The daily-reconciliation task endpoint returns 200 even when no
assertions exist it's the entry point that ops cron hits."""
r = await client.post(
"/libra/api/v1/tasks/daily-reconciliation",
headers=super_user_headers,
)
assert r.status_code == 200, f"daily-reconciliation: {r.status_code} {r.text}"

View file

@ -0,0 +1,202 @@
"""Settings and per-user wallet endpoints, plus the auth gates around them.
Endpoints and their auth profiles:
- `GET /libra/api/v1/settings` any authenticated user.
- `PUT /libra/api/v1/settings` `check_super_user` (Bearer, super-user only).
- `GET /libra/api/v1/user/wallet` `check_user_exists` (any authed user).
- `PUT /libra/api/v1/user/wallet` `check_user_exists`.
- `GET /libra/api/v1/user-wallet/{user_id}` `require_super_user` (libra
super-user via wallet admin-key auth).
Two distinct super-user auth flows live here side by side:
- LNbits-level `check_super_user` Bearer token from username/password login.
- Libra-level `require_super_user` wallet admin-key of the super-user-owned
wallet.
Tests use the `super_user_bearer_headers` fixture for the first, the
`super_user_headers` fixture for the second, and `?usr=<user_id>` for
non-admin authed calls.
"""
from uuid import uuid4
import pytest
@pytest.mark.anyio
async def test_super_user_can_get_and_update_settings(
client, super_user_bearer_headers, libra_wallet, fava_process,
):
"""Super user round-trips through `GET /settings` → mutate → `PUT /settings`.
Verifies the Bearer-auth happy path and confirms `update_settings`
persists what we sent (modulo defaults libra fills in).
"""
r = await client.get(
"/libra/api/v1/settings", headers=super_user_bearer_headers,
)
assert r.status_code == 200, f"GET /settings: {r.status_code} {r.text}"
original = r.json()
assert original.get("libra_wallet_id") == libra_wallet.id, (
f"libra_wallet fixture should have configured wallet_id, got {original}"
)
new_timeout = 7.5
r = await client.put(
"/libra/api/v1/settings",
headers=super_user_bearer_headers,
json={
"libra_wallet_id": libra_wallet.id,
"fava_url": fava_process,
"fava_ledger_slug": "libra-test",
"fava_timeout": new_timeout,
},
)
assert r.status_code == 200, f"PUT /settings: {r.status_code} {r.text}"
assert float(r.json().get("fava_timeout", 0)) == pytest.approx(new_timeout)
# Reset to keep other tests' baseline intact.
await client.put(
"/libra/api/v1/settings",
headers=super_user_bearer_headers,
json={
"libra_wallet_id": libra_wallet.id,
"fava_url": fava_process,
"fava_ledger_slug": "libra-test",
"fava_timeout": original.get("fava_timeout", 5.0),
},
)
@pytest.mark.anyio
async def test_put_settings_without_libra_wallet_id_returns_400(
client, super_user_bearer_headers,
):
"""The settings endpoint explicitly rejects updates with no wallet id.
This is the validation libra applies before any persistence so we don't
silently accept a settings row that breaks all entry endpoints.
"""
r = await client.put(
"/libra/api/v1/settings",
headers=super_user_bearer_headers,
json={"fava_url": "http://example.test"},
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "wallet" in r.text.lower()
@pytest.mark.anyio
async def test_put_settings_without_auth_returns_401(client, libra_wallet):
"""No auth at all → LNbits's `check_admin` rejects with 401."""
r = await client.put(
"/libra/api/v1/settings",
json={"libra_wallet_id": libra_wallet.id},
)
assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_regular_user_cannot_put_settings(
client, configured_user, libra_wallet,
):
"""A non-super user (regardless of auth method they try) cannot update
libra settings. Using `?usr=<id>` to mimic user-id login."""
user, _ = configured_user
r = await client.put(
f"/libra/api/v1/settings?usr={user.id}",
json={"libra_wallet_id": libra_wallet.id},
)
# `_check_account_exists` forbids user-id login for admin accounts and
# rejects regular users from `check_admin` paths — either 401 or 403
# is a valid no-access response here.
assert r.status_code in (401, 403), (
f"expected 401/403, got {r.status_code}: {r.text}"
)
@pytest.mark.anyio
async def test_regular_user_can_get_and_update_own_user_wallet(
client, libra_user, libra_wallet, # noqa: ARG001 (libra_wallet ensures session setup)
):
"""A regular user (no admin perm) can read and update their own
`user_wallet_id` via `?usr=<id>`."""
user, wallet = libra_user
r = await client.get(f"/libra/api/v1/user/wallet?usr={user.id}")
assert r.status_code == 200, f"GET /user/wallet: {r.status_code} {r.text}"
r = await client.put(
f"/libra/api/v1/user/wallet?usr={user.id}",
json={"user_wallet_id": wallet.id},
)
assert r.status_code == 200, f"PUT /user/wallet: {r.status_code} {r.text}"
r = await client.get(f"/libra/api/v1/user/wallet?usr={user.id}")
assert r.json().get("user_wallet_id") == wallet.id, (
f"GET after PUT should echo wallet id, got {r.json()}"
)
@pytest.mark.anyio
async def test_super_user_can_get_any_user_wallet(
client, super_user_headers, configured_user,
):
"""The `/user-wallet/{user_id}` endpoint (libra `require_super_user`,
wallet-admin-key auth) returns wallet info for any user."""
user, wallet = configured_user
r = await client.get(
f"/libra/api/v1/user-wallet/{user.id}", headers=super_user_headers,
)
assert r.status_code == 200, f"GET /user-wallet/{user.id}: {r.status_code} {r.text}"
payload = r.json()
assert payload.get("user_id") == user.id
assert payload.get("user_wallet_id") == wallet.id, (
f"expected user_wallet_id={wallet.id}, got {payload}"
)
@pytest.mark.anyio
async def test_regular_user_cannot_use_super_only_user_wallet_endpoint(
client, configured_user, configured_user_b,
):
"""User B can't see user A's wallet info via the super-only admin
endpoint, even with B's own wallet admin-key."""
user_a, _ = configured_user
_, wallet_b = configured_user_b
r = await client.get(
f"/libra/api/v1/user-wallet/{user_a.id}",
headers={"X-Api-Key": wallet_b.adminkey, "Content-type": "application/json"},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert "super" in r.text.lower()
@pytest.mark.anyio
async def test_unknown_currency_in_settings_does_not_corrupt(
client, super_user_bearer_headers, libra_wallet, fava_process,
):
"""Passing an unexpected field in the settings body shouldn't bring the
endpoint down pydantic should ignore extras and persist the rest.
A canary for "what if the UI sends a slightly-stale settings shape?"
"""
r = await client.put(
"/libra/api/v1/settings",
headers=super_user_bearer_headers,
json={
"libra_wallet_id": libra_wallet.id,
"fava_url": fava_process,
"fava_ledger_slug": "libra-test",
"some_unexpected_field_": str(uuid4()),
},
)
# Either 200 (extras dropped) or 422 (strict validation) — both are
# acceptable defensive behaviours; just don't 500.
assert r.status_code in (200, 422), (
f"unexpected field should be ignored or rejected cleanly, "
f"got {r.status_code}: {r.text}"
)

View file

@ -0,0 +1,342 @@
"""Settlement netting + credit overflow — libra-#33 + libra-#41.
`POST /libra/api/v1/receivables/settle` with `settled_entry_links=None`
(the default) auto-detects open entries in both directions, builds a
3-leg settlement transaction that zeros out both per-user accounts when
the user has open balances on both sides (libra-#33's nancy scenario),
and routes any excess cash to `Liabilities:Credit:User-X` (libra-#41).
Underpay without explicit entry-picks returns 400 with diff details so
the operator can either pay the exact net or specify `settled_entry_links`.
"""
import importlib
from uuid import uuid4
import pytest
from .helpers import (
approve_entry,
get_balance,
list_user_entries,
post_expense,
post_receivable,
settle_receivable,
)
def _libra_module(submodule: str):
for prefix in ("lnbits.extensions.libra", "libra"):
try:
return importlib.import_module(f"{prefix}.{submodule}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(f"libra.{submodule}")
async def _approve_and_refresh(client, wallet, super_user_headers, entry_id):
"""Approve a pending entry and force a Fava reload (libra-#37 workaround)."""
await approve_entry(client, super_user_headers=super_user_headers, entry_id=entry_id)
await list_user_entries(client, wallet_inkey=wallet.inkey)
# ---------------------------------------------------------------------------
# Nancy's #33 scenario and variants
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_exact_net_settlement_zeroes_both_per_user_accounts(
client, super_user_headers, configured_user, standard_accounts,
):
"""Nancy: receivable 100 EUR + payable 50 EUR + 50 EUR cash → 3-leg
settlement that zeros both Receivable and Payable for this user.
Acceptance criteria from libra-#33:
- Settlement links every source entry it reconciles.
- Per-user balances drop to 0 (not just net to 0 leaving each side open).
"""
user, wallet = configured_user
tag = uuid4().hex[:6]
# Admin records the receivable (cleared on creation).
await post_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount="100.00", currency="EUR",
description=f"Rent share {tag}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
# User submits an expense (pending until admin approves).
exp = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="50.00", currency="EUR",
description=f"Drill purchase {tag}",
expense_account=standard_accounts["expense_food"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, exp["id"])
# Sanity check: user owes 50 EUR net (100 receivable - 50 payable).
balance_before = await get_balance(client, wallet_inkey=wallet.inkey)
eur_before = balance_before.get("fiat_balances", {}).get("EUR")
assert float(eur_before) == pytest.approx(50.0), (
f"expected +50 EUR net (user owes libra), got {eur_before}"
)
# Settle the net cash: 50 EUR.
await settle_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount="50.00", currency="EUR",
description=f"Cash settlement {tag}",
)
await list_user_entries(client, wallet_inkey=wallet.inkey)
# After settlement: net balance is 0.
balance_after = await get_balance(client, wallet_inkey=wallet.inkey)
eur_after = balance_after.get("fiat_balances", {}).get("EUR", 0)
assert float(eur_after or 0) == pytest.approx(0.0), (
f"expected 0 EUR after exact net settlement, got {eur_after}"
)
# Per-account breakdown: every user-side account is at 0.
# (The acceptance criterion is that NEITHER Receivable nor Payable
# carries an open balance — not just that they net to 0.)
breakdown = balance_after.get("account_balances", [])
for row in breakdown:
if user.id[:8] in (row.get("account") or ""):
assert float(row.get("eur", 0) or 0) == pytest.approx(0.0), (
f"per-user account {row['account']} still has "
f"{row.get('eur')} EUR open after complete settlement; "
f"libra-#33 acceptance criterion violated"
)
# The settlement entry's links must cover both source entries.
# Both rcv-* and exp-* links should appear via Fava query.
fava_client_mod = _libra_module("fava_client")
fava = fava_client_mod.get_fava_client()
unsettled_receivables = await fava.get_unsettled_entries_bql(user.id, "receivable")
unsettled_payables = await fava.get_unsettled_entries_bql(user.id, "expense")
assert not unsettled_receivables, (
f"receivable left as unsettled after complete settlement: "
f"{unsettled_receivables}"
)
assert not unsettled_payables, (
f"payable left as unsettled after complete settlement: "
f"{unsettled_payables}"
)
@pytest.mark.anyio
async def test_overpay_routes_excess_to_credit(
client, super_user_headers, configured_user, standard_accounts,
):
"""Receivable 100 + payable 50 + cash 70 EUR → settles both per-user
accounts to 0, and the 20 EUR excess lands on Liabilities:Credit:User-X
(libra now owes the user 20 going forward).
Headline libra-#41 case: cash > net obligation absorbed into credit.
"""
user, wallet = configured_user
tag = uuid4().hex[:6]
await post_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount="100.00", currency="EUR",
description=f"Receivable {tag}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
exp = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="50.00", currency="EUR",
description=f"Payable {tag}",
expense_account=standard_accounts["expense_food"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, exp["id"])
# User pays 70 EUR — 20 EUR over the 50 EUR net obligation.
await settle_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount="70.00", currency="EUR",
description=f"Overpay settlement {tag}",
)
await list_user_entries(client, wallet_inkey=wallet.inkey)
# Net balance should be -20 EUR (libra owes user 20 via credit).
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert float(eur) == pytest.approx(-20.0), (
f"expected -20 EUR (libra owes user via credit), got {eur} from {balance}"
)
# Credit account should appear in the breakdown with -20 EUR.
breakdown = balance.get("account_balances", [])
credit_row = next(
(r for r in breakdown if "Credit" in (r.get("account") or "")), None,
)
assert credit_row is not None, (
f"Credit account missing from breakdown: {breakdown}"
)
assert float(credit_row.get("eur", 0)) == pytest.approx(-20.0), (
f"expected -20 EUR on Credit:User-X, got {credit_row.get('eur')}"
)
@pytest.mark.anyio
async def test_pure_receivable_overpay_creates_credit(
client, super_user_headers, configured_user, standard_accounts,
):
"""No payable side — receivable 50 + cash 70 → receivable cleared,
20 EUR moves to credit. 2-leg + credit overflow leg."""
user, wallet = configured_user
tag = uuid4().hex[:6]
await post_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount="50.00", currency="EUR",
description=f"Pure receivable {tag}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
await list_user_entries(client, wallet_inkey=wallet.inkey)
await settle_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount="70.00", currency="EUR",
description=f"Pure overpay {tag}",
)
await list_user_entries(client, wallet_inkey=wallet.inkey)
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
# Receivable cleared (0) - credit (-20) = -20 net
assert float(eur) == pytest.approx(-20.0), (
f"expected -20 EUR after pure overpay, got {eur}"
)
# ---------------------------------------------------------------------------
# Validation: underpay without explicit links → 400 with diff
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_underpay_without_explicit_links_returns_400(
client, super_user_headers, configured_user, standard_accounts,
):
"""Cash < net obligation and no `settled_entry_links` → 400 with the
diff payload so operator can fix the amount or specify entries.
Without #41's credit overflow + #33's auto-detect, this was the
silent-drift case that motivated both issues. Now: explicit, recoverable.
"""
user, wallet = configured_user
await post_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount="100.00", currency="EUR",
description="Receivable to underpay against",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
await list_user_entries(client, wallet_inkey=wallet.inkey)
r = await client.post(
"/libra/api/v1/receivables/settle",
headers=super_user_headers,
json={
"user_id": user.id,
"amount": "30.00",
"currency": "EUR",
"payment_method": "cash",
"description": "Underpay attempt",
},
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
payload = r.json().get("detail")
assert isinstance(payload, dict), f"expected structured detail, got {payload!r}"
assert payload.get("cash_paid") == 30.0
assert payload.get("net_obligation") == 100.0
assert payload.get("receivable_total") == 100.0
assert payload.get("payable_total") == 0.0
@pytest.mark.anyio
async def test_no_open_receivable_returns_400(
client, super_user_headers, configured_user,
):
"""User has no open receivables → endpoint can't settle. 400 with a
hint pointing at `/payables/pay` for the inverse direction."""
user, _ = configured_user
r = await client.post(
"/libra/api/v1/receivables/settle",
headers=super_user_headers,
json={
"user_id": user.id,
"amount": "50.00",
"currency": "EUR",
"payment_method": "cash",
"description": "Random deposit attempt",
},
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "no open receivables" in r.text.lower() or "payables/pay" in r.text
# ---------------------------------------------------------------------------
# Legacy explicit-links path: preserved for partial-settle-of-specific-entries
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_explicit_settled_entry_links_uses_legacy_2_leg_path(
client, super_user_headers, configured_user, standard_accounts,
):
"""When `settled_entry_links` is provided, backend trusts the caller's
list and writes the legacy 2-leg shape. No auto-netting, no credit
overflow validation. Required for callers that want to settle a
specific subset of entries.
Requires `amount_sats` per the legacy path's existing contract.
"""
user, wallet = configured_user
await post_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount="50.00", currency="EUR",
description="Receivable for explicit-link test",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
await list_user_entries(client, wallet_inkey=wallet.inkey)
# Caller passes explicit (but possibly empty) link list → legacy path.
r = await client.post(
"/libra/api/v1/receivables/settle",
headers=super_user_headers,
json={
"user_id": user.id,
"amount": "50.00",
"currency": "EUR",
"amount_sats": 55_000,
"payment_method": "cash",
"description": "Explicit-link settle",
"settled_entry_links": [], # opts out of auto-detect
},
)
assert r.status_code == 200, f"legacy explicit-link path: {r.status_code} {r.text}"

66
tests/test_smoke.py Normal file
View file

@ -0,0 +1,66 @@
"""Smoke test: validates the test harness end-to-end.
If this passes, the rest of the test files can be trusted to actually exercise
real code paths (Fava up, app up, Libra activated, FavaClient pointed at the
test instance, BQL round-trips working, libra wallet configured, user wallet
configured, account exists, permission granted).
If this fails, no point running anything else fix the harness first.
"""
import pytest
from .helpers import approve_entry, get_balance, post_expense
@pytest.mark.anyio
async def test_smoke_submit_approve_and_see_balance(
client, super_user_headers, configured_user, standard_accounts,
):
"""Full stack round-trip: user submits an expense, admin approves it,
balance reflects it.
Exercises: libra wallet config (session fixture), user wallet config
(configured_user fixture), permission grant (configured_user fixture),
Beancount entry construction, Fava add_entries HTTP call, pendingcleared
flag transition via the source-slice mutation path, BQL balance query
(which filters by flag = '*' so the approve step is load-bearing).
"""
_, wallet = configured_user
# User pays 50 EUR for groceries — entry posted with flag `!` (pending).
entry = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="50.00",
currency="EUR",
description="Smoke test expense",
expense_account=standard_accounts["expense_food"]["name"],
)
entry_id = entry.get("id")
assert entry_id, f"expense response missing id: {entry}"
# Pending entries are excluded from the cleared-only balance query —
# confirm balance is still zero at this point.
pending_balance = await get_balance(client, wallet_inkey=wallet.inkey)
pending_eur = pending_balance.get("fiat_balances", {}).get("EUR")
assert pending_eur in (None, 0, "0", "0.00"), (
f"pending expense should not affect cleared balance, got {pending_eur}"
)
# Admin approves the pending entry, flipping its flag from `!` to `*`.
await approve_entry(
client, super_user_headers=super_user_headers, entry_id=entry_id,
)
# Balance now reflects the 50 EUR Libra owes the user.
# Sign convention (per get_user_balance_bql docstring): the API returns
# the balance from libra's perspective — negative on Liabilities:Payable
# means libra owes the user. So a 50 EUR expense surfaces as -50 EUR.
balance = await get_balance(client, wallet_inkey=wallet.inkey)
fiat = balance.get("fiat_balances", {})
eur = fiat.get("EUR")
assert eur is not None, f"expected EUR in fiat_balances after approve, got {balance}"
assert float(eur) == pytest.approx(-50.0), (
f"expected EUR balance of -50.00 (libra-owes-user) after approve, got {eur}"
)

572
tests/test_unit.py Normal file
View file

@ -0,0 +1,572 @@
"""Pure-function unit tests — no harness, no Fava, no LNbits app.
Covers `libra.beancount_format`, `libra.account_utils`, `libra.core.validation`.
These modules have no external dependencies (stdlib + pydantic for models), so
they run fast and don't need fixtures.
The libra package is importable under either `lnbits.extensions.libra.*`
(default lnbits layout) or `libra.*` (LNBITS_EXTENSIONS_PATH override). The
`_module` helper tries both, mirroring the runtime-path discipline already
established in `conftest.py`.
"""
import importlib
from datetime import date
from decimal import Decimal
import pytest
def _module(name: str):
"""Import a libra submodule under whichever path the active LNbits layout
uses (default `lnbits.extensions.libra` or bare `libra`)."""
for prefix in ("lnbits.extensions.libra", "libra"):
try:
return importlib.import_module(f"{prefix}.{name}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(f"libra.{name}: tried both import paths")
bf = _module("beancount_format")
au = _module("account_utils")
val = _module("core.validation")
mdl = _module("models")
fc = _module("fava_client")
AccountType = mdl.AccountType
# ---------------------------------------------------------------------------
# fava_client._open_directive_exists — duplicate-account detection
# ---------------------------------------------------------------------------
def test_open_directive_exists_matches_real_directive():
src = "2020-01-01 open Expenses:Vehicle:Gas\n"
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True
def test_open_directive_exists_matches_currency_constrained_and_metadata():
src = (
"2020-01-01 open Expenses:Vehicle:Gas EUR, SATS\n"
' added_by: "abc"\n'
)
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True
def test_open_directive_exists_matches_inline_comment_without_space():
# Valid Beancount: the account token ends at ';'. The old (?:\\s|$) boundary
# missed this → duplicate Open written → bean-check breaks.
src = "2020-01-01 open Expenses:Vehicle:Gas;legacy-import\n"
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True
def test_open_directive_exists_ignores_name_inside_description():
# The name appears only inside another account's description metadata.
src = (
"2020-01-01 open Expenses:Notes\n"
' description: "remember to open Expenses:Vehicle:Gas next month"\n'
)
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
def test_open_directive_exists_ignores_comment_line():
src = "; TODO: open Expenses:Vehicle:Gas eventually\n"
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
def test_open_directive_exists_does_not_match_longer_sibling():
src = "2020-01-01 open Expenses:Vehicle:GasStation\n"
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
def test_open_directive_exists_does_not_match_deeper_child():
src = "2020-01-01 open Expenses:Vehicle:Gas:Premium\n"
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
@pytest.mark.parametrize(
"line",
[
"2024/3/5 open Expenses:Vehicle:Gas", # slash date, single-digit M/D
"2020-1-1 open Expenses:Vehicle:Gas", # dash date, single-digit M/D
"2020-01-01 open Expenses:Vehicle:Gas", # multiple spaces
"2020-01-01\topen\tExpenses:Vehicle:Gas", # tab separators
"1970-01-01 open Expenses:Vehicle:Gas EUR", # currency-constrained
],
)
def test_open_directive_exists_matches_beancount_date_and_whitespace_variants(line):
# All of these are valid Beancount Open directives per lexer.l's DATE token
# and ignored inter-token whitespace; each must be detected as existing.
assert fc._open_directive_exists(line + "\n", "Expenses:Vehicle:Gas") is True
# ---------------------------------------------------------------------------
# beancount_format.sanitize_link
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("raw", "expected"),
[
("libra-abc123", "libra-abc123"),
("Invoice #123", "Invoice-123"),
("Test (pending)", "Test-pending"),
("a/b.c-d_e", "a/b.c-d_e"), # all permitted chars survive
("multiple spaces", "multiple-spaces"), # collapsed
("---leading-trailing---", "leading-trailing"),
("ascii_only", "ascii_only"),
],
)
def test_sanitize_link_strips_unsafe_chars(raw, expected):
assert bf.sanitize_link(raw) == expected
def test_sanitize_link_empty_string_stays_empty():
assert bf.sanitize_link("") == ""
def test_sanitize_link_unicode_replaced_with_hyphens():
# Non-ascii chars all collapse to single hyphens, stripped from edges.
result = bf.sanitize_link("café résumé")
assert all(ch in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/."
for ch in result), f"unsanitized chars in {result!r}"
assert not result.startswith("-")
assert not result.endswith("-")
# ---------------------------------------------------------------------------
# beancount_format.format_transaction
# ---------------------------------------------------------------------------
def test_format_transaction_minimum_shape():
entry = bf.format_transaction(
date_val=date(2026, 6, 6),
flag="*",
narration="hello",
postings=[{"account": "Assets:Cash", "amount": "10 EUR"}],
)
# Fava's required fields.
assert entry["t"] == "Transaction"
assert entry["date"] == "2026-06-06"
assert entry["flag"] == "*"
assert entry["narration"] == "hello"
assert entry["payee"] == "" # empty string, not None
assert entry["tags"] == []
assert entry["links"] == []
assert entry["meta"] == {}
assert entry["postings"] == [{"account": "Assets:Cash", "amount": "10 EUR"}]
def test_format_transaction_optional_fields_are_passed_through():
entry = bf.format_transaction(
date_val=date(2026, 6, 6),
flag="!",
narration="pending lunch",
postings=[{"account": "Expenses:Food", "amount": "8 EUR"}],
payee="Bistro Local",
tags=["expense-entry"],
links=["libra-abc123"],
meta={"user-id": "abc12345"},
)
assert entry["flag"] == "!"
assert entry["payee"] == "Bistro Local"
assert entry["tags"] == ["expense-entry"]
assert entry["links"] == ["libra-abc123"]
assert entry["meta"] == {"user-id": "abc12345"}
def test_format_transaction_does_not_share_mutable_defaults():
"""Regression guard: passing `tags=None` shouldn't return the same list
every call (the classic Python mutable-default-argument trap)."""
a = bf.format_transaction(date(2026, 1, 1), "*", "a", [{"account": "X", "amount": "1 EUR"}])
b = bf.format_transaction(date(2026, 1, 2), "*", "b", [{"account": "Y", "amount": "1 EUR"}])
a["tags"].append("touched-a")
assert b["tags"] == [], "tags from one entry leaked into another"
# ---------------------------------------------------------------------------
# beancount_format.generate_entry_id
# ---------------------------------------------------------------------------
def test_generate_entry_id_shape():
eid = bf.generate_entry_id()
assert len(eid) == 16
assert all(c in "0123456789abcdef" for c in eid), f"non-hex in {eid!r}"
def test_generate_entry_ids_are_unique():
ids = {bf.generate_entry_id() for _ in range(100)}
assert len(ids) == 100 # 16 hex chars = 64 bits; collisions in 100 are negligible
# ---------------------------------------------------------------------------
# Entry identity contract — every libra-authored entry formatter must write
# `entry-id` metadata (the canonical id) and keep the user reference as its
# own sanitized link, never fused with the id.
# ---------------------------------------------------------------------------
def test_format_expense_entry_identity_contract():
entry = bf.format_expense_entry(
user_id="abc12345",
expense_account="Expenses:Food",
user_account="Liabilities:Payable:User-abc12345",
amount_sats=50000,
description="Groceries",
entry_date=date(2026, 6, 12),
fiat_currency="EUR",
fiat_amount=Decimal("46.50"),
reference="Invoice #123",
entry_id="deadbeef00000001",
)
assert entry["meta"]["entry-id"] == "deadbeef00000001"
assert "exp-deadbeef00000001" in entry["links"]
assert "Invoice-123" in entry["links"] # sanitized, standalone
def test_format_receivable_entry_identity_contract():
entry = bf.format_receivable_entry(
user_id="abc12345",
revenue_account="Income:Accommodation",
receivable_account="Assets:Receivable:User-abc12345",
amount_sats=100000,
description="2-night stay",
entry_date=date(2026, 6, 12),
fiat_currency="EUR",
fiat_amount=Decimal("93.00"),
reference="BOOKING/42",
entry_id="deadbeef00000002",
)
assert entry["meta"]["entry-id"] == "deadbeef00000002"
assert "rcv-deadbeef00000002" in entry["links"]
assert "BOOKING/42" in entry["links"]
def test_format_income_entry_identity_contract():
"""The production-bug shape: income + reference like '42-144'."""
entry = bf.format_income_entry(
user_id="abc12345",
user_account="Assets:Receivable:User-abc12345",
revenue_account="Income:MemberDuesContributions",
amount_sats=1112490,
description="2 Memberships",
entry_date=date(2026, 6, 12),
fiat_currency="USD",
fiat_amount=Decimal("700.00"),
reference="42-144",
entry_id="deadbeef00000003",
)
assert entry["meta"]["entry-id"] == "deadbeef00000003"
assert "inc-deadbeef00000003" in entry["links"]
assert "42-144" in entry["links"]
def test_format_revenue_entry_identity_contract():
entry = bf.format_revenue_entry(
payment_account="Assets:Cash",
revenue_account="Income:Sales",
amount_sats=100000,
description="Product sale",
entry_date=date(2026, 6, 12),
fiat_currency="EUR",
fiat_amount=Decimal("50.00"),
reference="Till receipt 9",
entry_id="deadbeef00000004",
)
assert entry["meta"]["entry-id"] == "deadbeef00000004"
assert "Till-receipt-9" in entry["links"] # sanitized
def test_format_revenue_entry_generates_entry_id_when_absent():
entry = bf.format_revenue_entry(
payment_account="Assets:Cash",
revenue_account="Income:Sales",
amount_sats=100000,
description="Product sale",
entry_date=date(2026, 6, 12),
)
eid = entry["meta"]["entry-id"]
assert len(eid) == 16 and all(c in "0123456789abcdef" for c in eid)
# ---------------------------------------------------------------------------
# account_utils.format_hierarchical_account_name
# ---------------------------------------------------------------------------
def test_format_hierarchical_simple_asset():
assert au.format_hierarchical_account_name(AccountType.ASSET, "Cash") == "Assets:Cash"
def test_format_hierarchical_user_specific_uses_8_char_prefix():
full_user_id = "af983632aabbccddeeff00112233445566"
name = au.format_hierarchical_account_name(
AccountType.ASSET, "Accounts Receivable", user_id=full_user_id,
)
assert name == "Assets:Receivable:User-af983632" # 8-char prefix, "Accounts " stripped
def test_format_hierarchical_ampersand_expands_to_colon():
"""`Food & Supplies` is a legacy display form; it becomes a hierarchy."""
name = au.format_hierarchical_account_name(AccountType.EXPENSE, "Food & Supplies")
assert name == "Expenses:Food:Supplies"
def test_format_hierarchical_revenue_uses_income_root():
"""Beancount uses `Income`, not `Revenue` — the mapping is in
`ACCOUNT_TYPE_ROOTS`."""
name = au.format_hierarchical_account_name(AccountType.REVENUE, "Accommodation")
assert name == "Income:Accommodation"
# ---------------------------------------------------------------------------
# account_utils.parse_legacy_account_name
# ---------------------------------------------------------------------------
def test_parse_legacy_with_user_suffix():
assert au.parse_legacy_account_name("Accounts Receivable - af983632") == (
"Accounts Receivable", "af983632",
)
def test_parse_legacy_without_user_suffix():
assert au.parse_legacy_account_name("Cash") == ("Cash", None)
# ---------------------------------------------------------------------------
# account_utils.format_account_display_name
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("hierarchical", "expected"),
[
("Assets:Receivable:User-af983632", "Accounts Receivable - af983632"),
("Liabilities:Payable:User-cafebabe", "Accounts Payable - cafebabe"),
("Expenses:Food:Supplies", "Food & Supplies"),
("Assets:Cash", "Cash"),
("Assets", "Assets"), # too short — passes through
],
)
def test_format_account_display_name(hierarchical, expected):
assert au.format_account_display_name(hierarchical) == expected
# ---------------------------------------------------------------------------
# account_utils.get_account_type_from_hierarchical
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("name", "expected_type"),
[
("Assets:Cash", AccountType.ASSET),
("Liabilities:Payable:User-x", AccountType.LIABILITY),
("Equity:User-x", AccountType.EQUITY),
("Income:Accommodation", AccountType.REVENUE),
("Expenses:Food", AccountType.EXPENSE),
],
)
def test_get_account_type_from_hierarchical(name, expected_type):
assert au.get_account_type_from_hierarchical(name) == expected_type
def test_get_account_type_unknown_root_returns_none():
assert au.get_account_type_from_hierarchical("Other:Random") is None
# ---------------------------------------------------------------------------
# account_utils.migrate_account_name — round-trip legacy → hierarchical
# ---------------------------------------------------------------------------
def test_migrate_account_name_receivable():
out = au.migrate_account_name("Accounts Receivable - af983632", AccountType.ASSET)
assert out == "Assets:Receivable:User-af983632"
def test_migrate_account_name_expense_with_ampersand():
assert au.migrate_account_name("Food & Supplies", AccountType.EXPENSE) == (
"Expenses:Food:Supplies"
)
# ---------------------------------------------------------------------------
# core.validation — validate_journal_entry
# ---------------------------------------------------------------------------
def test_validate_journal_entry_balanced_passes():
val.validate_journal_entry(
{"id": "x"},
[
{"account_id": "a", "amount": 100},
{"account_id": "b", "amount": -100},
],
)
def test_validate_journal_entry_unbalanced_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_journal_entry(
{"id": "x"},
[
{"account_id": "a", "amount": 100},
{"account_id": "b", "amount": -50},
],
)
assert "not balanced" in str(exc.value)
def test_validate_journal_entry_single_line_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_journal_entry(
{"id": "x"},
[{"account_id": "a", "amount": 100}],
)
assert "at least 2 lines" in str(exc.value)
def test_validate_journal_entry_zero_amount_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_journal_entry(
{"id": "x"},
[
{"account_id": "a", "amount": 0},
{"account_id": "b", "amount": 0},
],
)
assert "amount = 0" in str(exc.value)
def test_validate_journal_entry_missing_account_id_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_journal_entry(
{"id": "x"},
[
{"amount": 100},
{"account_id": "b", "amount": -100},
],
)
assert "missing account_id" in str(exc.value)
# ---------------------------------------------------------------------------
# core.validation — validate_balance
# ---------------------------------------------------------------------------
def test_validate_balance_exact_match_passes():
val.validate_balance("acct", expected_balance_sats=1000, actual_balance_sats=1000)
def test_validate_balance_within_tolerance_passes():
val.validate_balance(
"acct", expected_balance_sats=1000, actual_balance_sats=1005, tolerance_sats=10,
)
def test_validate_balance_outside_tolerance_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_balance(
"acct", expected_balance_sats=1000, actual_balance_sats=1100, tolerance_sats=10,
)
assert "Balance assertion failed" in str(exc.value)
def test_validate_balance_fiat_mismatch_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_balance(
"acct",
expected_balance_sats=1000,
actual_balance_sats=1000,
expected_balance_fiat=Decimal("100.00"),
actual_balance_fiat=Decimal("99.50"),
tolerance_fiat=Decimal("0.10"),
fiat_currency="EUR",
)
assert "Fiat balance" in str(exc.value)
# ---------------------------------------------------------------------------
# core.validation — entry-specific validators
# ---------------------------------------------------------------------------
def test_validate_receivable_entry_positive_revenue_passes():
val.validate_receivable_entry("u", amount=100, revenue_account_type="revenue")
def test_validate_receivable_entry_zero_amount_raises():
with pytest.raises(val.ValidationError):
val.validate_receivable_entry("u", amount=0, revenue_account_type="revenue")
def test_validate_receivable_entry_wrong_account_type_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_receivable_entry("u", amount=100, revenue_account_type="expense")
assert "revenue account" in str(exc.value)
def test_validate_expense_entry_non_equity_requires_expense_account():
with pytest.raises(val.ValidationError) as exc:
val.validate_expense_entry(
"u", amount=100, expense_account_type="asset", is_equity=False,
)
assert "expense account" in str(exc.value)
def test_validate_expense_entry_equity_allows_non_expense_account():
"""Equity contributions bypass the expense-account requirement."""
val.validate_expense_entry(
"u", amount=100, expense_account_type="equity", is_equity=True,
)
def test_validate_payment_entry_negative_raises():
with pytest.raises(val.ValidationError):
val.validate_payment_entry("u", amount=-1)
# ---------------------------------------------------------------------------
# core.validation — validate_metadata
# ---------------------------------------------------------------------------
def test_validate_metadata_required_keys_missing_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_metadata({"foo": 1}, required_keys=["bar", "baz"])
assert "bar" in str(exc.value) and "baz" in str(exc.value)
def test_validate_metadata_fiat_currency_without_amount_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_metadata({"fiat_currency": "EUR"})
assert "both be present or both absent" in str(exc.value)
def test_validate_metadata_fiat_amount_without_currency_raises():
with pytest.raises(val.ValidationError):
val.validate_metadata({"fiat_amount": "10.00"})
@pytest.mark.xfail(
reason="libra/issues/38 — except clause doesn't catch decimal.InvalidOperation, "
"so the raw exception leaks instead of becoming ValidationError. Flip when fixed.",
strict=True,
)
def test_validate_metadata_fiat_amount_invalid_decimal_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_metadata({"fiat_amount": "not-a-number", "fiat_currency": "EUR"})
assert "Invalid fiat_amount" in str(exc.value)
def test_validate_metadata_both_present_passes():
val.validate_metadata({"fiat_amount": "100.50", "fiat_currency": "EUR"})
def test_validate_metadata_neither_present_passes():
val.validate_metadata({"source": "api"})

View file

@ -0,0 +1,212 @@
"""Reject / void pending entry flow — `POST /libra/api/v1/entries/{id}/reject`.
Captures the current (pre-issue #24) in-place mutation behaviour:
- Pending entries (`!` flag) can be rejected by a super user.
- Rejection appends `#voided` to the transaction line in the .beancount file
(no new transaction posted this is the only in-place edit path in libra).
- Voided entries are filtered out of balance queries.
- The reject endpoint only matches pending entries; cleared (`*`) ones return
404 because the search loop filters by `flag == '!'`.
PR #34 changes whether the user's `/entries/user` listing surfaces voided rows.
The test `test_voided_entry_excluded_from_user_journal` documents the current
("filtered") behaviour; flip it if/when that change lands.
When the reversing-entry refactor in issue #24 ships, these tests will need to
move from "void via tag append" to "void via reversal transaction." The shape
of the tests should still hold what changes is the on-disk evidence.
"""
from uuid import uuid4
import pytest
from .helpers import (
approve_entry,
get_balance,
list_user_entries,
post_expense,
reject_entry,
)
@pytest.mark.anyio
async def test_admin_can_reject_pending_expense(
client, super_user_headers, configured_user, standard_accounts,
):
"""Happy path: user submits expense → admin rejects → response includes
the entry id, balance still zero."""
_, wallet = configured_user
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="15.00",
currency="EUR",
description=f"Reject me {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
result = await reject_entry(
client, super_user_headers=super_user_headers, entry_id=posted["id"],
)
assert result.get("entry_id") == posted["id"]
balance = await get_balance(client, wallet_inkey=wallet.inkey)
assert not balance.get("fiat_balances"), (
f"voided entry should not surface in balance, got {balance}"
)
@pytest.mark.anyio
async def test_voided_entry_visible_in_user_journal(
client, super_user_headers, configured_user, standard_accounts,
):
"""Post-commit-1c89e69 behaviour: rejected entries remain visible in
the user's `/entries/user` listing so the user can see their own
rejected history rather than having it silently disappear.
The UI is expected to render these with a 'voided' visual marker
(PR #34 webapp companion). The balance query still excludes them
via the separate `tags` filter covered in
`test_admin_can_reject_pending_expense`.
"""
_, wallet = configured_user
tag = f"void-marker-{uuid4().hex[:6]}"
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="20.00",
currency="EUR",
description=tag,
expense_account=standard_accounts["expense_food"]["name"],
)
await reject_entry(
client, super_user_headers=super_user_headers, entry_id=posted["id"],
)
listing = await list_user_entries(client, wallet_inkey=wallet.inkey)
entries = listing.get("entries", [])
descriptions = [e.get("description") or "" for e in entries]
assert any(tag in d for d in descriptions), (
f"voided entry should remain visible in user journal post-#34, "
f"got descriptions: {descriptions}"
)
voided = next(
(e for e in entries if tag in (e.get("description") or "")), None,
)
assert voided is not None
assert "voided" in voided.get("tags", []), (
f"voided entry should be tagged 'voided' for UI styling, "
f"got tags: {voided.get('tags')}"
)
@pytest.mark.anyio
async def test_reject_unknown_entry_returns_404(
client, super_user_headers,
):
"""An entry id that doesn't exist anywhere in the ledger 404s."""
bogus_id = uuid4().hex[:16]
r = await client.post(
f"/libra/api/v1/entries/{bogus_id}/reject",
headers=super_user_headers,
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
assert "not found" in r.text.lower()
@pytest.mark.anyio
async def test_reject_already_cleared_entry_returns_404(
client, super_user_headers, configured_user, standard_accounts,
):
"""The reject lookup filters by `flag == '!'` so already-approved
(cleared) entries are indistinguishable from non-existent ones
both 404."""
_, wallet = configured_user
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="11.00",
currency="EUR",
description=f"Approve-then-reject {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
await approve_entry(
client, super_user_headers=super_user_headers, entry_id=posted["id"],
)
r = await client.post(
f"/libra/api/v1/entries/{posted['id']}/reject",
headers=super_user_headers,
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_non_super_user_cannot_reject(
client, configured_user, standard_accounts,
):
"""Reject endpoint uses libra's `require_super_user` — wallet
admin-key of a non-super user is forbidden."""
_, wallet = configured_user
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="13.00",
currency="EUR",
description=f"Forbidden reject {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
r = await client.post(
f"/libra/api/v1/entries/{posted['id']}/reject",
headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert "super" in r.text.lower()
@pytest.mark.anyio
async def test_double_reject_returns_404_on_second_call(
client, super_user_headers, configured_user, standard_accounts,
):
"""After a successful reject the entry is no longer matched by the
lookup (it's still flag `!` but its journal-listing-filter behaviour
is "voided"). A second reject 404s rather than mutating again.
Documents the de-facto idempotency story: it's "first wins, repeat
fails cleanly" rather than "repeat is a no-op success." If the
reversing-entry refactor (#24) reshapes this, the test will reveal it.
"""
_, wallet = configured_user
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="9.00",
currency="EUR",
description=f"Double reject {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
await reject_entry(
client, super_user_headers=super_user_headers, entry_id=posted["id"],
)
r = await client.post(
f"/libra/api/v1/entries/{posted['id']}/reject",
headers=super_user_headers,
)
# First reject succeeded; second reject either 404 (entry still flag !
# but matched-by-tag elsewhere) or 200 with idempotent no-op. Lock in
# whichever the current code does so a future change to the reject
# path forces a deliberate decision.
assert r.status_code in (200, 404), (
f"second reject should be deterministic, got {r.status_code}: {r.text}"
)

View file

@ -4,22 +4,22 @@ from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
castle_generic_router = APIRouter(tags=["castle"])
libra_generic_router = APIRouter(tags=["libra"])
@castle_generic_router.get(
"/", description="Castle accounting home page", response_class=HTMLResponse
@libra_generic_router.get(
"/", description="Libra accounting home page", response_class=HTMLResponse
)
async def index(
request: Request,
user: User = Depends(check_user_exists),
):
return template_renderer(["castle/templates"]).TemplateResponse(
request, "castle/index.html", {"user": user.json()}
return template_renderer(["libra/templates"]).TemplateResponse(
request, "libra/index.html", {"user": user.json()}
)
@castle_generic_router.get(
@libra_generic_router.get(
"/permissions",
description="Permission management page",
response_class=HTMLResponse,
@ -28,6 +28,6 @@ async def permissions(
request: Request,
user: User = Depends(check_user_exists),
):
return template_renderer(["castle/templates"]).TemplateResponse(
request, "castle/permissions.html", {"user": user.json()}
return template_renderer(["libra/templates"]).TemplateResponse(
request, "libra/permissions.html", {"user": user.json()}
)

File diff suppressed because it is too large Load diff