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>
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>
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>
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>
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>
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
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
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
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
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
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>
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>
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).
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>
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.
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.
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.
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
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.
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
- 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>
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>
- Add settled_entry_links parameter to format_payment_entry and format_net_settlement_entry
- Query unsettled expenses/receivables before creating settlement entries
- Pass original entry links to format functions so settlements reference what they settle
- Update all callers in views_api.py (5 locations) and tasks.py (1 location)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Pass entry_id from views_api.py to format_expense_entry and
format_receivable_entry so that all links use the same ID:
- ^castle-{entry_id}
- ^exp-{entry_id} / ^rcv-{entry_id}
- entry-id metadata
Previously, views_api.py generated an entry_id for castle-* links
but didn't pass it to the format functions, which generated their
own separate IDs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Use GET /api/context instead of GET /api/source_slice. Fava's API
naming convention means source_slice only supports PUT and DELETE,
while context is the correct endpoint for reading entry data.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Both settlement dialogs now fetch BOTH expense and receivable entries
to properly link all entries being netted in a settlement.
This ensures that when a user has:
- 2 expenses (80 EUR - castle owes user)
- 1 receivable (1000 EUR - user owes castle)
The net settlement (920 EUR) includes links to all three entries:
^exp-xxx ^exp-xxx ^rcv-xxx
This allows proper tracking of which specific entries were settled.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
The frontend now:
1. Fetches unsettled entries when opening settlement dialogs
2. Includes entry links (exp-xxx/rcv-xxx) in settlement payloads
3. Passes settled_entry_links to backend for proper linking
This enables the settlement transaction to include links back to
the original entries it is settling, making it possible to track
which expenses/receivables have been paid.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Added typing.Optional import that was missing after adding the
report endpoints with optional date parameters.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
New endpoints:
- GET /api/v1/reports/expenses - Expense summary by account or month
- GET /api/v1/reports/contributions - User contribution totals
New FavaClient methods:
- get_expense_summary_bql() - Aggregates expenses with date filtering
- get_user_contributions_bql() - Aggregates user expense submissions
Both use sum(weight) for efficient SATS aggregation from price notation.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Use Fava's 'time' query parameter to filter entries on the server
instead of fetching all entries and filtering in Python.
This reduces:
- Data transfer (only relevant entries are sent)
- Memory usage (no need to hold all entries)
- Processing time (no Python-side date parsing/filtering)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Replace sum(position) with sum(weight) for efficient SATS aggregation
from price notation. Also return fiat amount from sum(number).
This simplifies the parsing logic and provides consistent SATS totals
across all BQL-based balance methods.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Replace inefficient approach that fetched ALL journal entries with
targeted BQL queries that:
- Filter by account pattern and tags in the database
- Use weight column for SATS amounts (no string parsing)
- Query only expense/receivable entries for the specific user
This significantly reduces data transfer and processing overhead.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Use Math.abs() to display liability amounts as positive values in the
Pay User dialog. Liabilities are stored as negative (castle owes user)
but should display as positive when framed as "Amount Castle Owes".
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
The /context endpoint returns entry metadata but not the editable source.
The /source_slice endpoint returns the actual source text and sha256sum
needed for approving/rejecting entries.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Now that the ledger uses @@ SATS price notation, BQL can efficiently
aggregate SATS balances using the weight column instead of manual
entry-by-entry aggregation.
Changes:
- fava_client.py: Update get_user_balance_bql() and get_all_user_balances_bql()
to use sum(weight) for SATS aggregation (5-10x performance improvement)
- views_api.py: Switch all balance endpoints to use BQL methods
The weight column returns the @@ price value, enabling server-side
filtering and aggregation instead of fetching all entries.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>