Compare commits
No commits in common. "main" and "v0.0.3" have entirely different histories.
61 changed files with 1705 additions and 8022 deletions
95
CLAUDE.md
95
CLAUDE.md
|
|
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -12,9 +12,9 @@ Libra is a double-entry bookkeeping extension for LNbits that enables collective
|
||||||
|
|
||||||
**Double-Entry Accounting**: Every transaction affects at least two accounts. Debits must equal credits. Five account types: Assets, Liabilities, Equity, Revenue (Income), Expenses.
|
**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**: 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.
|
**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.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
**Pure Functional Core**: The `core/` directory contains pure accounting logic independent of the database layer:
|
**Pure Functional Core**: The `core/` directory contains pure accounting logic independent of the database layer:
|
||||||
- `core/validation.py` - Entry validation rules
|
- `core/validation.py` - Entry validation rules
|
||||||
|
|
@ -44,19 +44,23 @@ Libra is a double-entry bookkeeping extension for LNbits that enables collective
|
||||||
- `tasks.py` - Background tasks (invoice payment monitoring)
|
- `tasks.py` - Background tasks (invoice payment monitoring)
|
||||||
- `account_utils.py` - Hierarchical account naming utilities
|
- `account_utils.py` - Hierarchical account naming utilities
|
||||||
- `fava_client.py` - HTTP client for Fava REST API (add_entry, query, balance_sheet)
|
- `fava_client.py` - HTTP client for Fava REST API (add_entry, query, balance_sheet)
|
||||||
- `beancount_format.py` - Converts Libra entries to Beancount transaction format
|
- `beancount_format.py` - Converts Castle entries to Beancount transaction format
|
||||||
- `core/validation.py` - Pure validation functions for accounting rules
|
- `core/validation.py` - Pure validation functions for accounting rules
|
||||||
|
|
||||||
### Database Schema
|
### Database Schema
|
||||||
|
|
||||||
**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.
|
**Note**: With Fava integration, Castle maintains a local cache of some data but delegates authoritative balance calculations to Beancount/Fava.
|
||||||
|
|
||||||
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.
|
**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)
|
||||||
|
|
||||||
**extension_settings**: Libra wallet configuration (admin-only)
|
**extension_settings**: Castle wallet configuration (admin-only)
|
||||||
- `libra_wallet_id` - The LNbits wallet used for Libra operations
|
- `castle_wallet_id` - The LNbits wallet used for Castle operations
|
||||||
- `fava_url` - Fava service URL (default: http://localhost:3333)
|
- `fava_url` - Fava service URL (default: http://localhost:3333)
|
||||||
- `fava_ledger_slug` - Ledger identifier in Fava (default: libra-accounting)
|
- `fava_ledger_slug` - Ledger identifier in Fava (default: castle-accounting)
|
||||||
- `fava_timeout` - API request timeout in seconds
|
- `fava_timeout` - API request timeout in seconds
|
||||||
|
|
||||||
**user_wallet_settings**: Per-user wallet configuration
|
**user_wallet_settings**: Per-user wallet configuration
|
||||||
|
|
@ -66,22 +70,22 @@ The SQLite tables below hold **operational state** that Fava doesn't (and should
|
||||||
## Transaction Flows
|
## Transaction Flows
|
||||||
|
|
||||||
### User Adds Expense (Liability)
|
### User Adds Expense (Liability)
|
||||||
User pays cash for groceries, Libra owes them:
|
User pays cash for groceries, Castle owes them:
|
||||||
```
|
```
|
||||||
DR Expenses:Food 39,669 sats
|
DR Expenses:Food 39,669 sats
|
||||||
CR Liabilities:Payable:User-af983632 39,669 sats
|
CR Liabilities:Payable:User-af983632 39,669 sats
|
||||||
```
|
```
|
||||||
Metadata preserves: `{"fiat_currency": "EUR", "fiat_amount": "36.93", "fiat_rate": "1074.192"}`
|
Metadata preserves: `{"fiat_currency": "EUR", "fiat_amount": "36.93", "fiat_rate": "1074.192"}`
|
||||||
|
|
||||||
### Libra Adds Receivable
|
### Castle Adds Receivable
|
||||||
User owes Libra for accommodation:
|
User owes Castle for accommodation:
|
||||||
```
|
```
|
||||||
DR Assets:Receivable:User-af983632 268,548 sats
|
DR Assets:Receivable:User-af983632 268,548 sats
|
||||||
CR Income:Accommodation 268,548 sats
|
CR Income:Accommodation 268,548 sats
|
||||||
```
|
```
|
||||||
|
|
||||||
### User Pays with Lightning
|
### User Pays with Lightning
|
||||||
Invoice generated on **Libra's wallet** (not user's). After payment:
|
Invoice generated on **Castle's wallet** (not user's). After payment:
|
||||||
```
|
```
|
||||||
DR Assets:Lightning:Balance 268,548 sats
|
DR Assets:Lightning:Balance 268,548 sats
|
||||||
CR Assets:Receivable:User-af983632 268,548 sats
|
CR Assets:Receivable:User-af983632 268,548 sats
|
||||||
|
|
@ -97,14 +101,14 @@ DR Liabilities:Payable:User-af983632 39,669 sats
|
||||||
## Balance Calculation Logic
|
## Balance Calculation Logic
|
||||||
|
|
||||||
**User Balance** (calculated by Beancount via Fava):
|
**User Balance** (calculated by Beancount via Fava):
|
||||||
- Positive = Libra owes user (LIABILITY accounts have credit balance)
|
- Positive = Castle owes user (LIABILITY accounts have credit balance)
|
||||||
- Negative = User owes Libra (ASSET accounts have debit balance)
|
- Negative = User owes Castle (ASSET accounts have debit balance)
|
||||||
- Calculated by querying Fava for sum of all postings across user's accounts
|
- 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
|
- Fiat balances calculated by Beancount from cost basis annotations, NOT converted from current sats
|
||||||
|
|
||||||
**Perspective-Based UI**:
|
**Perspective-Based UI**:
|
||||||
- **User View**: Green = Libra owes them, Red = They owe Libra
|
- **User View**: Green = Castle owes them, Red = They owe Castle
|
||||||
- **Libra Admin View**: Green = User owes Libra, Red = Libra owes user
|
- **Castle Admin View**: Green = User owes Castle, Red = Castle owes user
|
||||||
|
|
||||||
**Balance Retrieval**: Use `GET /api/v1/balance` which queries Fava's balance sheet or account reports for accurate, Beancount-calculated balances.
|
**Balance Retrieval**: Use `GET /api/v1/balance` which queries Fava's balance sheet or account reports for accurate, Beancount-calculated balances.
|
||||||
|
|
||||||
|
|
@ -123,12 +127,12 @@ DR Liabilities:Payable:User-af983632 39,669 sats
|
||||||
- `POST /api/v1/entries` - Create raw journal entry (admin only)
|
- `POST /api/v1/entries` - Create raw journal entry (admin only)
|
||||||
|
|
||||||
### Payments & Balances
|
### Payments & Balances
|
||||||
- `GET /api/v1/balance` - Get user balance (or Libra total if super user)
|
- `GET /api/v1/balance` - Get user balance (or Castle total if super user)
|
||||||
- `GET /api/v1/balances/all` - Get all user balances (admin, enriched with usernames)
|
- `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 Libra
|
- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Castle
|
||||||
- `POST /api/v1/record-payment` - Record Lightning payment from user to Libra
|
- `POST /api/v1/record-payment` - Record Lightning payment from user to Castle
|
||||||
- `POST /api/v1/settle-receivable` - Manually settle receivable (cash/bank)
|
- `POST /api/v1/settle-receivable` - Manually settle receivable (cash/bank)
|
||||||
- `POST /api/v1/pay-user` - Libra pays user (cash/bank/lightning)
|
- `POST /api/v1/pay-user` - Castle pays user (cash/bank/lightning)
|
||||||
|
|
||||||
### Manual Payment Requests
|
### Manual Payment Requests
|
||||||
- `POST /api/v1/manual-payment-requests` - User requests payment
|
- `POST /api/v1/manual-payment-requests` - User requests payment
|
||||||
|
|
@ -144,8 +148,8 @@ DR Liabilities:Payable:User-af983632 39,669 sats
|
||||||
- `POST /api/v1/tasks/daily-reconciliation` - Run daily reconciliation (admin)
|
- `POST /api/v1/tasks/daily-reconciliation` - Run daily reconciliation (admin)
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
- `GET /api/v1/settings` - Get Libra settings (super user)
|
- `GET /api/v1/settings` - Get Castle settings (super user)
|
||||||
- `PUT /api/v1/settings` - Update Libra settings (super user)
|
- `PUT /api/v1/settings` - Update Castle settings (super user)
|
||||||
- `GET /api/v1/user/wallet` - Get user wallet settings
|
- `GET /api/v1/user/wallet` - Get user wallet settings
|
||||||
- `PUT /api/v1/user/wallet` - Update user wallet settings
|
- `PUT /api/v1/user/wallet` - Update user wallet settings
|
||||||
|
|
||||||
|
|
@ -209,8 +213,7 @@ entry = format_transaction(
|
||||||
{"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"}
|
{"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"}
|
||||||
],
|
],
|
||||||
tags=["groceries"],
|
tags=["groceries"],
|
||||||
links=["exp-a1b2c3d4e5f60708"], # typed settlement link; identity goes in entry-id metadata
|
links=["castle-entry-123"]
|
||||||
meta={"entry-id": "a1b2c3d4e5f60708"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Submit to Fava
|
# Submit to Fava
|
||||||
|
|
@ -218,8 +221,6 @@ client = get_fava_client()
|
||||||
result = await client.add_entry(entry)
|
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**:
|
**Querying Balances**:
|
||||||
```python
|
```python
|
||||||
# Query user balance from Fava
|
# Query user balance from Fava
|
||||||
|
|
@ -240,15 +241,15 @@ balance_result = await client.query(
|
||||||
### Extension as LNbits Module
|
### Extension as LNbits Module
|
||||||
|
|
||||||
This extension follows LNbits extension structure:
|
This extension follows LNbits extension structure:
|
||||||
- Registered via `libra_ext` router in `__init__.py`
|
- Registered via `castle_ext` router in `__init__.py`
|
||||||
- Static files served from `static/` directory
|
- Static files served from `static/` directory
|
||||||
- Templates in `templates/libra/`
|
- Templates in `templates/castle/`
|
||||||
- Database accessed via `db = Database("ext_libra")`
|
- Database accessed via `db = Database("ext_castle")`
|
||||||
|
|
||||||
**Startup Requirements**:
|
**Startup Requirements**:
|
||||||
- `libra_start()` initializes Fava client on extension load
|
- `castle_start()` initializes Fava client on extension load
|
||||||
- Background task `wait_for_paid_invoices()` monitors Lightning invoice payments
|
- Background task `wait_for_paid_invoices()` monitors Lightning invoice payments
|
||||||
- Fava service MUST be running before starting LNbits with Libra extension
|
- Fava service MUST be running before starting LNbits with Castle extension
|
||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
|
|
@ -281,8 +282,7 @@ entry = format_transaction(
|
||||||
{"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"}
|
{"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"}
|
||||||
],
|
],
|
||||||
tags=["utilities"],
|
tags=["utilities"],
|
||||||
links=["exp-0123456789abcdef"],
|
links=["castle-tx-123"]
|
||||||
meta={"entry-id": "0123456789abcdef"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
client = get_fava_client()
|
client = get_fava_client()
|
||||||
|
|
@ -310,13 +310,6 @@ result = await client.query(query)
|
||||||
3. User accounts use `user_id` (NOT `wallet_id`) for consistency
|
3. User accounts use `user_id` (NOT `wallet_id`) for consistency
|
||||||
4. All accounting calculations delegated to Beancount/Fava
|
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`:
|
**Validation** is performed in `core/validation.py`:
|
||||||
- Pure validation functions for entry correctness before submitting to Fava
|
- Pure validation functions for entry correctness before submitting to Fava
|
||||||
|
|
||||||
|
|
@ -344,24 +337,24 @@ result = await client.query(query)
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
1. **LNbits**: This extension must be installed in the `lnbits/extensions/` directory
|
1. **LNbits**: This extension must be installed in the `lnbits/extensions/` directory
|
||||||
2. **Fava Service**: Must be running before starting LNbits with Libra enabled
|
2. **Fava Service**: Must be running before starting LNbits with Castle enabled
|
||||||
```bash
|
```bash
|
||||||
# Install Fava
|
# Install Fava
|
||||||
pip install fava
|
pip install fava
|
||||||
|
|
||||||
# Create a basic Beancount file
|
# Create a basic Beancount file
|
||||||
touch libra-ledger.beancount
|
touch castle-ledger.beancount
|
||||||
|
|
||||||
# Start Fava (default: http://localhost:3333)
|
# Start Fava (default: http://localhost:3333)
|
||||||
fava libra-ledger.beancount
|
fava castle-ledger.beancount
|
||||||
```
|
```
|
||||||
3. **Configure Libra Settings**: Set `fava_url` and `fava_ledger_slug` via settings API or UI
|
3. **Configure Castle Settings**: Set `fava_url` and `fava_ledger_slug` via settings API or UI
|
||||||
|
|
||||||
### Running Libra Extension
|
### Running Castle Extension
|
||||||
|
|
||||||
Libra is loaded as part of LNbits. No separate build or test commands are needed for the extension itself. Development workflow:
|
Castle 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/libra/`
|
1. Modify code in `lnbits/extensions/castle/`
|
||||||
2. Restart LNbits
|
2. Restart LNbits
|
||||||
3. Extension hot-reloads are supported by LNbits in development mode
|
3. Extension hot-reloads are supported by LNbits in development mode
|
||||||
|
|
||||||
|
|
@ -370,13 +363,13 @@ Libra is loaded as part of LNbits. No separate build or test commands are needed
|
||||||
Use the web UI or API endpoints to create test transactions. For API testing:
|
Use the web UI or API endpoints to create test transactions. For API testing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create expense (user owes Libra)
|
# Create expense (user owes Castle)
|
||||||
curl -X POST http://localhost:5000/libra/api/v1/entries/expense \
|
curl -X POST http://localhost:5000/castle/api/v1/entries/expense \
|
||||||
-H "X-Api-Key: YOUR_INVOICE_KEY" \
|
-H "X-Api-Key: YOUR_INVOICE_KEY" \
|
||||||
-d '{"description": "Test expense", "amount": "100.00 EUR", "account_name": "Expenses:Test"}'
|
-d '{"description": "Test expense", "amount": "100.00 EUR", "account_name": "Expenses:Test"}'
|
||||||
|
|
||||||
# Check user balance
|
# Check user balance
|
||||||
curl http://localhost:5000/libra/api/v1/balance \
|
curl http://localhost:5000/castle/api/v1/balance \
|
||||||
-H "X-Api-Key: YOUR_INVOICE_KEY"
|
-H "X-Api-Key: YOUR_INVOICE_KEY"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
# Libra Migration Squash Summary
|
# Castle Migration Squash Summary
|
||||||
|
|
||||||
**Date:** November 10, 2025
|
**Date:** November 10, 2025
|
||||||
**Action:** Squashed 16 incremental migrations into a single clean initial migration
|
**Action:** Squashed 16 incremental migrations into a single clean initial migration
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Files Changed
|
## Files Changed
|
||||||
|
|
||||||
|
|
@ -16,37 +16,37 @@ The Libra extension had accumulated 16 migrations (m001-m016) during development
|
||||||
|
|
||||||
The squashed migration creates **7 tables**:
|
The squashed migration creates **7 tables**:
|
||||||
|
|
||||||
### 1. libra_accounts
|
### 1. castle_accounts
|
||||||
- Core chart of accounts with hierarchical Beancount-style names
|
- Core chart of accounts with hierarchical Beancount-style names
|
||||||
- Examples: "Assets:Bitcoin:Lightning", "Expenses:Food:Groceries"
|
- Examples: "Assets:Bitcoin:Lightning", "Expenses:Food:Groceries"
|
||||||
- User-specific accounts: "Assets:Receivable:User-af983632"
|
- User-specific accounts: "Assets:Receivable:User-af983632"
|
||||||
- Includes comprehensive default account set (40+ accounts)
|
- Includes comprehensive default account set (40+ accounts)
|
||||||
|
|
||||||
### 2. libra_extension_settings
|
### 2. castle_extension_settings
|
||||||
- Libra-wide configuration
|
- Castle-wide configuration
|
||||||
- Stores libra_wallet_id for Lightning payments
|
- Stores castle_wallet_id for Lightning payments
|
||||||
|
|
||||||
### 3. libra_user_wallet_settings
|
### 3. castle_user_wallet_settings
|
||||||
- Per-user wallet configuration
|
- Per-user wallet configuration
|
||||||
- Allows users to have separate wallet preferences
|
- Allows users to have separate wallet preferences
|
||||||
|
|
||||||
### 4. libra_manual_payment_requests
|
### 4. castle_manual_payment_requests
|
||||||
- User-submitted payment requests to Libra
|
- User-submitted payment requests to Castle
|
||||||
- Reviewed by admins before processing
|
- Reviewed by admins before processing
|
||||||
- Includes notes field for additional context
|
- Includes notes field for additional context
|
||||||
|
|
||||||
### 5. libra_balance_assertions
|
### 5. castle_balance_assertions
|
||||||
- Reconciliation and balance checking at specific dates
|
- Reconciliation and balance checking at specific dates
|
||||||
- Multi-currency support (satoshis + fiat)
|
- Multi-currency support (satoshis + fiat)
|
||||||
- Tolerance checking for small discrepancies
|
- Tolerance checking for small discrepancies
|
||||||
- Includes notes field for reconciliation comments
|
- Includes notes field for reconciliation comments
|
||||||
|
|
||||||
### 6. libra_user_equity_status
|
### 6. castle_user_equity_status
|
||||||
- Manages equity contribution eligibility
|
- Manages equity contribution eligibility
|
||||||
- Equity-eligible users can convert expenses to equity
|
- Equity-eligible users can convert expenses to equity
|
||||||
- Creates dynamic user-specific equity accounts: Equity:User-{user_id}
|
- Creates dynamic user-specific equity accounts: Equity:User-{user_id}
|
||||||
|
|
||||||
### 7. libra_account_permissions
|
### 7. castle_account_permissions
|
||||||
- Granular access control for accounts
|
- Granular access control for accounts
|
||||||
- Permission types: read, submit_expense, manage
|
- Permission types: read, submit_expense, manage
|
||||||
- Supports hierarchical inheritance (parent permissions cascade)
|
- 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):
|
The following tables were **intentionally NOT included** in the final schema (they were dropped in m016):
|
||||||
|
|
||||||
- **libra_journal_entries** - Journal entries now managed by Fava/Beancount (external source of truth)
|
- **castle_journal_entries** - Journal entries now managed by Fava/Beancount (external source of truth)
|
||||||
- **libra_entry_lines** - Entry lines now managed by Fava/Beancount
|
- **castle_entry_lines** - Entry lines now managed by Fava/Beancount
|
||||||
|
|
||||||
Libra now uses Fava as the single source of truth for accounting data. Journal operations:
|
Castle now uses Fava as the single source of truth for accounting data. Journal operations:
|
||||||
- **Write:** Submit to Fava via FavaClient.add_entry()
|
- **Write:** Submit to Fava via FavaClient.add_entry()
|
||||||
- **Read:** Query Fava via FavaClient.get_entries()
|
- **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:
|
For new installations:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Libra's migration system will run m001_initial automatically
|
# Castle's migration system will run m001_initial automatically
|
||||||
# No manual intervention needed
|
# No manual intervention needed
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -174,20 +174,20 @@ After squashing, verify the migration works:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Backup existing database (if any)
|
# 1. Backup existing database (if any)
|
||||||
cp libra.sqlite3 libra.sqlite3.backup
|
cp castle.sqlite3 castle.sqlite3.backup
|
||||||
|
|
||||||
# 2. Drop and recreate database to test fresh install
|
# 2. Drop and recreate database to test fresh install
|
||||||
rm libra.sqlite3
|
rm castle.sqlite3
|
||||||
|
|
||||||
# 3. Start LNbits - migration should run automatically
|
# 3. Start LNbits - migration should run automatically
|
||||||
poetry run lnbits
|
poetry run lnbits
|
||||||
|
|
||||||
# 4. Verify tables created
|
# 4. Verify tables created
|
||||||
sqlite3 libra.sqlite3 ".tables"
|
sqlite3 castle.sqlite3 ".tables"
|
||||||
# Should show: libra_accounts, libra_extension_settings, etc.
|
# Should show: castle_accounts, castle_extension_settings, etc.
|
||||||
|
|
||||||
# 5. Verify default accounts
|
# 5. Verify default accounts
|
||||||
sqlite3 libra.sqlite3 "SELECT COUNT(*) FROM libra_accounts;"
|
sqlite3 castle.sqlite3 "SELECT COUNT(*) FROM castle_accounts;"
|
||||||
# Should show: 40 (default accounts)
|
# Should show: 40 (default accounts)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -200,12 +200,12 @@ If issues are discovered:
|
||||||
cp migrations_old.py.bak migrations.py
|
cp migrations_old.py.bak migrations.py
|
||||||
|
|
||||||
# Restore database
|
# Restore database
|
||||||
cp libra.sqlite3.backup libra.sqlite3
|
cp castle.sqlite3.backup castle.sqlite3
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- This squash is safe because Libra has not been released yet
|
- This squash is safe because Castle has not been released yet
|
||||||
- No existing production databases need migration
|
- No existing production databases need migration
|
||||||
- Historical migrations preserved in migrations_old.py.bak
|
- Historical migrations preserved in migrations_old.py.bak
|
||||||
- All functionality preserved in final schema
|
- All functionality preserved in final schema
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -1,10 +1,10 @@
|
||||||
# Libra Extension for LNbits
|
# Castle Accounting Extension for LNbits
|
||||||
|
|
||||||
A full-featured double-entry accounting system for collective projects, integrated with LNbits Lightning payments.
|
A full-featured double-entry accounting system for collective projects, integrated with LNbits Lightning payments.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Libra enables collectives like co-living spaces, makerspaces, and community projects to:
|
Castle Accounting enables collectives like co-living spaces, makerspaces, and community projects to:
|
||||||
- Track expenses and revenue with proper accounting
|
- Track expenses and revenue with proper accounting
|
||||||
- Manage individual member balances
|
- Manage individual member balances
|
||||||
- Record contributions as equity or reimbursable expenses
|
- Record contributions as equity or reimbursable expenses
|
||||||
|
|
@ -17,7 +17,7 @@ This extension is designed to be installed in the `lnbits/extensions/` directory
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd lnbits/extensions/
|
cd lnbits/extensions/
|
||||||
# Copy or clone the libra directory here
|
# Copy or clone the castle directory here
|
||||||
```
|
```
|
||||||
|
|
||||||
Enable the extension through the LNbits admin interface or by adding it to your configuration.
|
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 "Liability" if you want reimbursement
|
||||||
- Choose "Equity" if it's a contribution
|
- Choose "Equity" if it's a contribution
|
||||||
|
|
||||||
2. **View Your Balance**: See if the collective owes you money or vice versa
|
2. **View Your Balance**: See if the Castle owes you money or vice versa
|
||||||
|
|
||||||
3. **Pay Outstanding Balance**: Generate a Lightning invoice to settle what you owe
|
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
|
### Account Types
|
||||||
|
|
||||||
- **Assets**: Things the organization owns (Cash, Bank, Accounts Receivable)
|
- **Assets**: Things the Castle owns (Cash, Bank, Accounts Receivable)
|
||||||
- **Liabilities**: What the organization owes (Accounts Payable to members)
|
- **Liabilities**: What the Castle owes (Accounts Payable to members)
|
||||||
- **Equity**: Member contributions and retained earnings
|
- **Equity**: Member contributions and retained earnings
|
||||||
- **Revenue**: Income streams
|
- **Revenue**: Income streams
|
||||||
- **Expenses**: Operating costs
|
- **Expenses**: Operating costs
|
||||||
|
|
@ -63,9 +63,9 @@ Enable the extension through the LNbits admin interface or by adding it to your
|
||||||
### Database Schema
|
### Database Schema
|
||||||
|
|
||||||
The extension creates three tables:
|
The extension creates three tables:
|
||||||
- `libra.accounts` - Chart of accounts
|
- `castle.accounts` - Chart of accounts
|
||||||
- `libra.journal_entries` - Transaction headers
|
- `castle.journal_entries` - Transaction headers
|
||||||
- `libra.entry_lines` - Debit/credit lines
|
- `castle.entry_lines` - Debit/credit lines
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
|
|
@ -79,7 +79,7 @@ To modify this extension:
|
||||||
2. Add database migrations in `migrations.py`
|
2. Add database migrations in `migrations.py`
|
||||||
3. Implement business logic in `crud.py`
|
3. Implement business logic in `crud.py`
|
||||||
4. Create API endpoints in `views_api.py`
|
4. Create API endpoints in `views_api.py`
|
||||||
5. Update UI in `templates/libra/index.html`
|
5. Update UI in `templates/castle/index.html`
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|
|
||||||
71
__init__.py
71
__init__.py
|
|
@ -5,24 +5,24 @@ from loguru import logger
|
||||||
|
|
||||||
from .crud import db
|
from .crud import db
|
||||||
from .tasks import wait_for_paid_invoices
|
from .tasks import wait_for_paid_invoices
|
||||||
from .views import libra_generic_router
|
from .views import castle_generic_router
|
||||||
from .views_api import libra_api_router
|
from .views_api import castle_api_router
|
||||||
|
|
||||||
libra_ext: APIRouter = APIRouter(prefix="/libra", tags=["Libra"])
|
castle_ext: APIRouter = APIRouter(prefix="/castle", tags=["Castle"])
|
||||||
libra_ext.include_router(libra_generic_router)
|
castle_ext.include_router(castle_generic_router)
|
||||||
libra_ext.include_router(libra_api_router)
|
castle_ext.include_router(castle_api_router)
|
||||||
|
|
||||||
libra_static_files = [
|
castle_static_files = [
|
||||||
{
|
{
|
||||||
"path": "/libra/static",
|
"path": "/castle/static",
|
||||||
"name": "libra_static",
|
"name": "castle_static",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
scheduled_tasks: list[asyncio.Task] = []
|
scheduled_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
|
|
||||||
def libra_stop():
|
def castle_stop():
|
||||||
"""Clean up background tasks on extension shutdown"""
|
"""Clean up background tasks on extension shutdown"""
|
||||||
for task in scheduled_tasks:
|
for task in scheduled_tasks:
|
||||||
try:
|
try:
|
||||||
|
|
@ -31,54 +31,35 @@ def libra_stop():
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
|
|
||||||
|
|
||||||
def libra_start():
|
def castle_start():
|
||||||
"""Initialize Libra extension background tasks"""
|
"""Initialize Castle extension background tasks"""
|
||||||
from lnbits.tasks import create_permanent_unique_task
|
from lnbits.tasks import create_permanent_unique_task
|
||||||
from .fava_client import init_fava_client
|
from .fava_client import init_fava_client
|
||||||
from .models import LibraSettings
|
from .models import CastleSettings
|
||||||
from .tasks import wait_for_account_sync
|
from .tasks import wait_for_account_sync
|
||||||
|
|
||||||
async def _init_fava():
|
# Initialize Fava client with default settings
|
||||||
"""Load saved settings from DB, fall back to defaults."""
|
# (Will be re-initialized if admin updates settings)
|
||||||
from .crud import db as libra_db
|
defaults = CastleSettings()
|
||||||
|
|
||||||
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=settings.fava_url,
|
|
||||||
ledger_slug=settings.fava_ledger_slug,
|
|
||||||
timeout=settings.fava_timeout
|
|
||||||
)
|
|
||||||
logger.info(f"Fava client initialized: {settings.fava_url}/{settings.fava_ledger_slug}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
asyncio.get_event_loop().create_task(_init_fava())
|
init_fava_client(
|
||||||
|
fava_url=defaults.fava_url,
|
||||||
|
ledger_slug=defaults.fava_ledger_slug,
|
||||||
|
timeout=defaults.fava_timeout
|
||||||
|
)
|
||||||
|
logger.info(f"Fava client initialized: {defaults.fava_url}/{defaults.fava_ledger_slug}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize Fava client: {e}")
|
logger.error(f"Failed to initialize Fava client: {e}")
|
||||||
logger.warning("Libra will not function without Fava. Please configure Fava settings.")
|
logger.warning("Castle will not function without Fava. Please configure Fava settings.")
|
||||||
|
|
||||||
# Start background tasks
|
# Start background tasks
|
||||||
task = create_permanent_unique_task("ext_libra", wait_for_paid_invoices)
|
task = create_permanent_unique_task("ext_castle", wait_for_paid_invoices)
|
||||||
scheduled_tasks.append(task)
|
scheduled_tasks.append(task)
|
||||||
|
|
||||||
# Start account sync task (runs hourly)
|
# Start account sync task (runs hourly)
|
||||||
sync_task = create_permanent_unique_task("ext_libra_account_sync", wait_for_account_sync)
|
sync_task = create_permanent_unique_task("ext_castle_account_sync", wait_for_account_sync)
|
||||||
scheduled_tasks.append(sync_task)
|
scheduled_tasks.append(sync_task)
|
||||||
logger.info("Libra account sync task started (runs hourly)")
|
logger.info("Castle account sync task started (runs hourly)")
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["libra_ext", "libra_static_files", "db", "libra_start", "libra_stop"]
|
__all__ = ["castle_ext", "castle_static_files", "db", "castle_start", "castle_stop"]
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"""
|
"""
|
||||||
Account Synchronization Module
|
Account Synchronization Module
|
||||||
|
|
||||||
Syncs accounts from Beancount (source of truth) to Libra DB (metadata store).
|
Syncs accounts from Beancount (source of truth) to Castle DB (metadata store).
|
||||||
|
|
||||||
This implements the hybrid approach:
|
This implements the hybrid approach:
|
||||||
- Beancount owns account existence (Open directives)
|
- Beancount owns account existence (Open directives)
|
||||||
- Libra DB stores permissions and user associations
|
- Castle DB stores permissions and user associations
|
||||||
- Background sync keeps them in sync
|
- Background sync keeps them in sync
|
||||||
|
|
||||||
Related: ACCOUNTS-TABLE-REMOVAL-FEASIBILITY.md - Phase 2 implementation
|
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:
|
async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
|
||||||
"""
|
"""
|
||||||
Sync accounts from Beancount to Libra DB.
|
Sync accounts from Beancount to Castle DB.
|
||||||
|
|
||||||
This ensures Libra DB has metadata entries for all accounts that exist
|
This ensures Castle DB has metadata entries for all accounts that exist
|
||||||
in Beancount, enabling permissions and user associations to work properly.
|
in Beancount, enabling permissions and user associations to work properly.
|
||||||
|
|
||||||
New behavior (soft delete + virtual parents):
|
New behavior (soft delete + virtual parents):
|
||||||
- Accounts in Beancount but not in Libra DB: Added as active
|
- Accounts in Beancount but not in Castle DB: Added as active
|
||||||
- Accounts in Libra DB but not in Beancount: Marked as inactive (soft delete)
|
- Accounts in Castle DB but not in Beancount: Marked as inactive (soft delete)
|
||||||
- Inactive accounts that return to Beancount: Reactivated
|
- Inactive accounts that return to Beancount: Reactivated
|
||||||
- Missing intermediate parents: Auto-created as virtual accounts
|
- 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:
|
dict with sync statistics:
|
||||||
{
|
{
|
||||||
"total_beancount_accounts": 150,
|
"total_beancount_accounts": 150,
|
||||||
"total_libra_accounts": 148,
|
"total_castle_accounts": 148,
|
||||||
"accounts_added": 2,
|
"accounts_added": 2,
|
||||||
"accounts_updated": 0,
|
"accounts_updated": 0,
|
||||||
"accounts_skipped": 148,
|
"accounts_skipped": 148,
|
||||||
|
|
@ -123,7 +123,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
|
||||||
"errors": []
|
"errors": []
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
logger.info("Starting account sync from Beancount to Libra DB")
|
logger.info("Starting account sync from Beancount to Castle DB")
|
||||||
|
|
||||||
fava = get_fava_client()
|
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}")
|
logger.error(f"Failed to fetch accounts from Beancount: {e}")
|
||||||
return {
|
return {
|
||||||
"total_beancount_accounts": 0,
|
"total_beancount_accounts": 0,
|
||||||
"total_libra_accounts": 0,
|
"total_castle_accounts": 0,
|
||||||
"accounts_added": 0,
|
"accounts_added": 0,
|
||||||
"accounts_updated": 0,
|
"accounts_updated": 0,
|
||||||
"accounts_skipped": 0,
|
"accounts_skipped": 0,
|
||||||
|
|
@ -143,16 +143,16 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
|
||||||
"errors": [str(e)],
|
"errors": [str(e)],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get all accounts from Libra DB (including inactive ones for sync)
|
# Get all accounts from Castle DB (including inactive ones for sync)
|
||||||
libra_accounts = await get_all_accounts(include_inactive=True)
|
castle_accounts = await get_all_accounts(include_inactive=True)
|
||||||
|
|
||||||
# Build lookup maps
|
# Build lookup maps
|
||||||
beancount_account_names = {acc["account"] for acc in beancount_accounts}
|
beancount_account_names = {acc["account"] for acc in beancount_accounts}
|
||||||
libra_accounts_by_name = {acc.name: acc for acc in libra_accounts}
|
castle_accounts_by_name = {acc.name: acc for acc in castle_accounts}
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
"total_beancount_accounts": len(beancount_accounts),
|
"total_beancount_accounts": len(beancount_accounts),
|
||||||
"total_libra_accounts": len(libra_accounts),
|
"total_castle_accounts": len(castle_accounts),
|
||||||
"accounts_added": 0,
|
"accounts_added": 0,
|
||||||
"accounts_updated": 0,
|
"accounts_updated": 0,
|
||||||
"accounts_skipped": 0,
|
"accounts_skipped": 0,
|
||||||
|
|
@ -162,15 +162,15 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
|
||||||
"errors": [],
|
"errors": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Step 1: Sync accounts from Beancount to Libra DB
|
# Step 1: Sync accounts from Beancount to Castle DB
|
||||||
for bc_account in beancount_accounts:
|
for bc_account in beancount_accounts:
|
||||||
account_name = bc_account["account"]
|
account_name = bc_account["account"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
existing = libra_accounts_by_name.get(account_name)
|
existing = castle_accounts_by_name.get(account_name)
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# Account exists in Libra DB
|
# Account exists in Castle DB
|
||||||
# Check if it needs to be reactivated
|
# Check if it needs to be reactivated
|
||||||
if not existing.is_active:
|
if not existing.is_active:
|
||||||
await update_account_is_active(existing.id, True)
|
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}")
|
logger.debug(f"Account already active: {account_name}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Create new account in Libra DB
|
# Create new account in Castle DB
|
||||||
account_type = infer_account_type_from_name(account_name)
|
account_type = infer_account_type_from_name(account_name)
|
||||||
user_id = extract_user_id_from_account_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)
|
logger.error(error_msg)
|
||||||
stats["errors"].append(error_msg)
|
stats["errors"].append(error_msg)
|
||||||
|
|
||||||
# Step 2: Mark orphaned accounts (in Libra DB but not in Beancount) as inactive
|
# Step 2: Mark orphaned accounts (in Castle DB but not in Beancount) as inactive
|
||||||
# SKIP virtual accounts (they're intentionally metadata-only)
|
# SKIP virtual accounts (they're intentionally metadata-only)
|
||||||
for libra_account in libra_accounts:
|
for castle_account in castle_accounts:
|
||||||
if libra_account.is_virtual:
|
if castle_account.is_virtual:
|
||||||
# Virtual accounts are metadata-only, never deactivate them
|
# Virtual accounts are metadata-only, never deactivate them
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if libra_account.name not in beancount_account_names:
|
if castle_account.name not in beancount_account_names:
|
||||||
# Account no longer exists in Beancount
|
# Account no longer exists in Beancount
|
||||||
if libra_account.is_active:
|
if castle_account.is_active:
|
||||||
try:
|
try:
|
||||||
await update_account_is_active(libra_account.id, False)
|
await update_account_is_active(castle_account.id, False)
|
||||||
stats["accounts_deactivated"] += 1
|
stats["accounts_deactivated"] += 1
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Deactivated orphaned account: {libra_account.name}"
|
f"Deactivated orphaned account: {castle_account.name}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = (
|
error_msg = (
|
||||||
f"Failed to deactivate account {libra_account.name}: {e}"
|
f"Failed to deactivate account {castle_account.name}: {e}"
|
||||||
)
|
)
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
stats["errors"].append(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
|
# 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
|
# Otherwise we'll be checking against stale data and miss newly synced children
|
||||||
current_libra_accounts = await get_all_accounts(include_inactive=True)
|
current_castle_accounts = await get_all_accounts(include_inactive=True)
|
||||||
all_account_names = {acc.name for acc in current_libra_accounts}
|
all_account_names = {acc.name for acc in current_castle_accounts}
|
||||||
|
|
||||||
for bc_account in beancount_accounts:
|
for bc_account in beancount_accounts:
|
||||||
account_name = bc_account["account"]
|
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:
|
async def sync_single_account_from_beancount(account_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Sync a single account from Beancount to Libra DB.
|
Sync a single account from Beancount to Castle DB.
|
||||||
|
|
||||||
Useful for ensuring a specific account exists in Libra DB before
|
Useful for ensuring a specific account exists in Castle DB before
|
||||||
granting permissions on it.
|
granting permissions on it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -318,22 +318,13 @@ async def sync_single_account_from_beancount(account_name: str) -> bool:
|
||||||
logger.error(f"Account not found in Beancount: {account_name}")
|
logger.error(f"Account not found in Beancount: {account_name}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Create in Libra DB
|
# Create in Castle DB
|
||||||
account_type = infer_account_type_from_name(account_name)
|
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
|
description = None
|
||||||
meta_user_id = None
|
|
||||||
if "meta" in bc_account and isinstance(bc_account["meta"], dict):
|
if "meta" in bc_account and isinstance(bc_account["meta"], dict):
|
||||||
description = bc_account["meta"].get("description")
|
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(
|
await create_account(
|
||||||
CreateAccount(
|
CreateAccount(
|
||||||
|
|
@ -352,9 +343,9 @@ async def sync_single_account_from_beancount(account_name: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def ensure_account_exists_in_libra(account_name: str) -> bool:
|
async def ensure_account_exists_in_castle(account_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Ensure account exists in Libra DB, creating from Beancount if needed.
|
Ensure account exists in Castle DB, creating from Beancount if needed.
|
||||||
|
|
||||||
This is the recommended function to call before granting permissions.
|
This is the recommended function to call before granting permissions.
|
||||||
|
|
||||||
|
|
@ -364,7 +355,7 @@ async def ensure_account_exists_in_libra(account_name: str) -> bool:
|
||||||
Returns:
|
Returns:
|
||||||
True if account exists (or was created), False if failed
|
True if account exists (or was created), False if failed
|
||||||
"""
|
"""
|
||||||
# Check Libra DB first
|
# Check Castle DB first
|
||||||
existing = await get_account_by_name(account_name)
|
existing = await get_account_by_name(account_name)
|
||||||
if existing:
|
if existing:
|
||||||
return True
|
return True
|
||||||
|
|
@ -376,9 +367,9 @@ async def ensure_account_exists_in_libra(account_name: str) -> bool:
|
||||||
# Background sync task (can be scheduled with cron or async scheduler)
|
# Background sync task (can be scheduled with cron or async scheduler)
|
||||||
async def scheduled_account_sync():
|
async def scheduled_account_sync():
|
||||||
"""
|
"""
|
||||||
Scheduled task to sync accounts from Beancount to Libra DB.
|
Scheduled task to sync accounts from Beancount to Castle DB.
|
||||||
|
|
||||||
Run this periodically (e.g., every hour) to keep Libra DB in sync with Beancount.
|
Run this periodically (e.g., every hour) to keep Castle DB in sync with Beancount.
|
||||||
|
|
||||||
Example with APScheduler:
|
Example with APScheduler:
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
|
||||||
|
|
@ -200,11 +200,11 @@ DEFAULT_HIERARCHICAL_ACCOUNTS = [
|
||||||
("Assets:FixedAssets:ProductionFacility", AccountType.ASSET, "Production facilities"),
|
("Assets:FixedAssets:ProductionFacility", AccountType.ASSET, "Production facilities"),
|
||||||
("Assets:Inventory", AccountType.ASSET, "Inventory and stock"),
|
("Assets:Inventory", AccountType.ASSET, "Inventory and stock"),
|
||||||
("Assets:Livestock", AccountType.ASSET, "Livestock and animals"),
|
("Assets:Livestock", AccountType.ASSET, "Livestock and animals"),
|
||||||
("Assets:Receivable", AccountType.ASSET, "Money owed to the organization"),
|
("Assets:Receivable", AccountType.ASSET, "Money owed to the Castle"),
|
||||||
("Assets:Tools", AccountType.ASSET, "Tools and hand equipment"),
|
("Assets:Tools", AccountType.ASSET, "Tools and hand equipment"),
|
||||||
|
|
||||||
# Liabilities
|
# Liabilities
|
||||||
("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the organization"),
|
("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the Castle"),
|
||||||
|
|
||||||
# Equity - User equity accounts created dynamically as Equity:User-{user_id}
|
# Equity - User equity accounts created dynamically as Equity:User-{user_id}
|
||||||
# No parent "Equity" account needed - hierarchy is implicit in the name
|
# No parent "Equity" account needed - hierarchy is implicit in the name
|
||||||
|
|
|
||||||
310
auth.py
310
auth.py
|
|
@ -1,310 +0,0 @@
|
||||||
"""
|
|
||||||
Centralized Authorization Module for Libra Extension.
|
|
||||||
|
|
||||||
Provides consistent, secure authorization patterns across all endpoints.
|
|
||||||
|
|
||||||
Key concepts:
|
|
||||||
- AuthContext: Captures all authorization state for a request
|
|
||||||
- Dependencies: FastAPI dependencies for endpoint protection
|
|
||||||
- Permission checks: Consistent resource-level access control
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from .auth import require_super_user, require_authenticated, AuthContext
|
|
||||||
|
|
||||||
@router.get("/api/v1/admin-endpoint")
|
|
||||||
async def admin_endpoint(auth: AuthContext = Depends(require_super_user)):
|
|
||||||
# Only super users can access
|
|
||||||
pass
|
|
||||||
|
|
||||||
@router.get("/api/v1/user-data")
|
|
||||||
async def user_data(auth: AuthContext = Depends(require_authenticated)):
|
|
||||||
# Any authenticated user
|
|
||||||
user_id = auth.user_id
|
|
||||||
pass
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import wraps
|
|
||||||
from http import HTTPStatus
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException
|
|
||||||
from lnbits.core.models import WalletTypeInfo
|
|
||||||
from lnbits.decorators import require_admin_key, require_invoice_key
|
|
||||||
from lnbits.settings import settings as lnbits_settings
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from .crud import get_account, get_user_permissions
|
|
||||||
from .models import PermissionType
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AuthContext:
|
|
||||||
"""
|
|
||||||
Authorization context for a request.
|
|
||||||
|
|
||||||
Contains all information needed to make authorization decisions.
|
|
||||||
Use this instead of directly accessing wallet/user properties scattered
|
|
||||||
throughout endpoint code.
|
|
||||||
"""
|
|
||||||
user_id: str
|
|
||||||
wallet_id: str
|
|
||||||
is_super_user: bool
|
|
||||||
wallet: WalletTypeInfo
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_admin(self) -> bool:
|
|
||||||
"""
|
|
||||||
Check if user is a Libra admin (super user).
|
|
||||||
|
|
||||||
Note: In Libra, admin = super_user. There's no separate admin concept.
|
|
||||||
"""
|
|
||||||
return self.is_super_user
|
|
||||||
|
|
||||||
def require_super_user(self) -> None:
|
|
||||||
"""Raise HTTPException if not super user."""
|
|
||||||
if not self.is_super_user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
|
||||||
detail="Super user access required"
|
|
||||||
)
|
|
||||||
|
|
||||||
def require_self_or_super_user(self, target_user_id: str) -> None:
|
|
||||||
"""
|
|
||||||
Require that user is accessing their own data or is super user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
target_user_id: The user ID being accessed
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If user is neither the target nor super user
|
|
||||||
"""
|
|
||||||
if not self.is_super_user and self.user_id != target_user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
|
||||||
detail="Access denied: you can only access your own data"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_auth_context(wallet: WalletTypeInfo) -> AuthContext:
|
|
||||||
"""Build AuthContext from wallet info."""
|
|
||||||
user_id = wallet.wallet.user
|
|
||||||
return AuthContext(
|
|
||||||
user_id=user_id,
|
|
||||||
wallet_id=wallet.wallet.id,
|
|
||||||
is_super_user=user_id == lnbits_settings.super_user,
|
|
||||||
wallet=wallet,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ===== FastAPI Dependencies =====
|
|
||||||
|
|
||||||
async def require_authenticated(
|
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
||||||
) -> AuthContext:
|
|
||||||
"""
|
|
||||||
Require authentication (invoice key minimum).
|
|
||||||
|
|
||||||
Returns AuthContext with user information.
|
|
||||||
Use for read-only access to user's own data.
|
|
||||||
"""
|
|
||||||
return _build_auth_context(wallet)
|
|
||||||
|
|
||||||
|
|
||||||
async def require_authenticated_write(
|
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
||||||
) -> AuthContext:
|
|
||||||
"""
|
|
||||||
Require authentication with write permissions (admin key).
|
|
||||||
|
|
||||||
Returns AuthContext with user information.
|
|
||||||
Use for write operations on user's own data.
|
|
||||||
"""
|
|
||||||
return _build_auth_context(wallet)
|
|
||||||
|
|
||||||
|
|
||||||
async def require_super_user(
|
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
||||||
) -> AuthContext:
|
|
||||||
"""
|
|
||||||
Require super user access.
|
|
||||||
|
|
||||||
Raises HTTPException 403 if not super user.
|
|
||||||
Use for Libra admin operations.
|
|
||||||
"""
|
|
||||||
auth = _build_auth_context(wallet)
|
|
||||||
if not auth.is_super_user:
|
|
||||||
logger.warning(
|
|
||||||
f"Super user access denied for user {auth.user_id[:8]} "
|
|
||||||
f"attempting admin operation"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
|
||||||
detail="Super user access required"
|
|
||||||
)
|
|
||||||
return auth
|
|
||||||
|
|
||||||
|
|
||||||
# ===== Resource Access Checks =====
|
|
||||||
|
|
||||||
async def can_access_account(
|
|
||||||
auth: AuthContext,
|
|
||||||
account_id: str,
|
|
||||||
permission_type: PermissionType = PermissionType.READ,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Check if user can access an account.
|
|
||||||
|
|
||||||
Access is granted if:
|
|
||||||
1. User is super user (full access)
|
|
||||||
2. User owns the account (user-specific accounts like Assets:Receivable:User-abc123)
|
|
||||||
3. User has explicit permission for the account
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: The authorization context
|
|
||||||
account_id: The account ID to check
|
|
||||||
permission_type: The type of access needed (READ, SUBMIT_EXPENSE, MANAGE)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if access is allowed, False otherwise
|
|
||||||
"""
|
|
||||||
# Super users have full access
|
|
||||||
if auth.is_super_user:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check if this is the user's own account
|
|
||||||
account = await get_account(account_id)
|
|
||||||
if account:
|
|
||||||
user_short = auth.user_id[:8]
|
|
||||||
if f"User-{user_short}" in account.name:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check explicit permissions
|
|
||||||
permissions = await get_user_permissions(auth.user_id)
|
|
||||||
for perm in permissions:
|
|
||||||
if perm.account_id == account_id:
|
|
||||||
# Check if permission type is sufficient
|
|
||||||
if perm.permission_type == PermissionType.MANAGE:
|
|
||||||
return True # MANAGE grants all access
|
|
||||||
if perm.permission_type == permission_type:
|
|
||||||
return True
|
|
||||||
if (
|
|
||||||
permission_type == PermissionType.READ
|
|
||||||
and perm.permission_type in [PermissionType.SUBMIT_EXPENSE, PermissionType.MANAGE]
|
|
||||||
):
|
|
||||||
return True # Higher permissions include READ
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def require_account_access(
|
|
||||||
auth: AuthContext,
|
|
||||||
account_id: str,
|
|
||||||
permission_type: PermissionType = PermissionType.READ,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Require access to an account, raising HTTPException if denied.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: The authorization context
|
|
||||||
account_id: The account ID to check
|
|
||||||
permission_type: The type of access needed
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If access is denied
|
|
||||||
"""
|
|
||||||
if not await can_access_account(auth, account_id, permission_type):
|
|
||||||
logger.warning(
|
|
||||||
f"Account access denied: user {auth.user_id[:8]} "
|
|
||||||
f"attempted {permission_type.value} on account {account_id}"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
|
||||||
detail=f"Access denied to account {account_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def can_access_user_data(auth: AuthContext, target_user_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if user can access another user's data.
|
|
||||||
|
|
||||||
Access is granted if:
|
|
||||||
1. User is super user
|
|
||||||
2. User is accessing their own data
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: The authorization context
|
|
||||||
target_user_id: The user ID whose data is being accessed
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if access is allowed
|
|
||||||
"""
|
|
||||||
if auth.is_super_user:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Users can access their own data - compare full ID or short ID
|
|
||||||
if auth.user_id == target_user_id:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Also allow if short IDs match (8 char prefix)
|
|
||||||
if auth.user_id[:8] == target_user_id[:8]:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def require_user_data_access(
|
|
||||||
auth: AuthContext,
|
|
||||||
target_user_id: str,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Require access to a user's data, raising HTTPException if denied.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: The authorization context
|
|
||||||
target_user_id: The user ID whose data is being accessed
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If access is denied
|
|
||||||
"""
|
|
||||||
if not await can_access_user_data(auth, target_user_id):
|
|
||||||
logger.warning(
|
|
||||||
f"User data access denied: user {auth.user_id[:8]} "
|
|
||||||
f"attempted to access data for user {target_user_id[:8]}"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
|
||||||
detail="Access denied: you can only access your own data"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ===== Utility Functions =====
|
|
||||||
|
|
||||||
def get_user_id_from_wallet(wallet: WalletTypeInfo) -> str:
|
|
||||||
"""
|
|
||||||
Get user ID from wallet info.
|
|
||||||
|
|
||||||
IMPORTANT: Always use wallet.wallet.user (not wallet.wallet.id).
|
|
||||||
- wallet.wallet.user = the user's ID
|
|
||||||
- wallet.wallet.id = the wallet's ID (NOT the same!)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
wallet: The wallet type info from LNbits
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The user ID
|
|
||||||
"""
|
|
||||||
return wallet.wallet.user
|
|
||||||
|
|
||||||
|
|
||||||
def is_super_user(user_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if a user ID is the super user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: The user ID to check
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if this is the super user
|
|
||||||
"""
|
|
||||||
return user_id == lnbits_settings.super_user
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""
|
"""
|
||||||
Format Libra entries as Beancount transactions for Fava API.
|
Format Castle entries as Beancount transactions for Fava API.
|
||||||
|
|
||||||
All entries submitted to Fava must follow Beancount syntax.
|
All entries submitted to Fava must follow Beancount syntax.
|
||||||
This module converts Libra data models to Fava API format.
|
This module converts Castle data models to Fava API format.
|
||||||
|
|
||||||
Key concepts:
|
Key concepts:
|
||||||
- Amounts are strings: "200000 SATS" or "100.00 EUR"
|
- Amounts are strings: "200000 SATS" or "100.00 EUR"
|
||||||
|
|
@ -35,8 +35,8 @@ def sanitize_link(text: str) -> str:
|
||||||
'Test-pending'
|
'Test-pending'
|
||||||
>>> sanitize_link("Invoice #123")
|
>>> sanitize_link("Invoice #123")
|
||||||
'Invoice-123'
|
'Invoice-123'
|
||||||
>>> sanitize_link("libra-abc123")
|
>>> sanitize_link("castle-abc123")
|
||||||
'libra-abc123'
|
'castle-abc123'
|
||||||
"""
|
"""
|
||||||
# Replace any character that's not alphanumeric, dash, underscore, slash, or period with a hyphen
|
# Replace any character that's not alphanumeric, dash, underscore, slash, or period with a hyphen
|
||||||
sanitized = re.sub(r'[^A-Za-z0-9\-_/.]', '-', text)
|
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)
|
postings: List of posting dicts (formatted by format_posting)
|
||||||
payee: Optional payee
|
payee: Optional payee
|
||||||
tags: Optional tags (e.g., ["expense-entry", "approved"])
|
tags: Optional tags (e.g., ["expense-entry", "approved"])
|
||||||
links: Optional links (e.g., ["libra-abc123", "^invoice-xyz"])
|
links: Optional links (e.g., ["castle-abc123", "^invoice-xyz"])
|
||||||
meta: Optional transaction metadata
|
meta: Optional transaction metadata
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -93,8 +93,8 @@ def format_transaction(
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
tags=["expense-entry"],
|
tags=["expense-entry"],
|
||||||
links=["libra-abc123"],
|
links=["castle-abc123"],
|
||||||
meta={"user-id": "abc123", "source": "libra-expense-entry"}
|
meta={"user-id": "abc123", "source": "castle-expense-entry"}
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
|
|
@ -150,7 +150,7 @@ def format_posting_with_cost(
|
||||||
"""
|
"""
|
||||||
Format a posting with cost basis for Fava API.
|
Format a posting with cost basis for Fava API.
|
||||||
|
|
||||||
This is the RECOMMENDED format for all Libra transactions.
|
This is the RECOMMENDED format for all Castle transactions.
|
||||||
Uses Beancount's cost basis syntax to preserve exchange rates.
|
Uses Beancount's cost basis syntax to preserve exchange rates.
|
||||||
|
|
||||||
IMPORTANT: Beancount cost syntax uses PER-UNIT cost, not total cost.
|
IMPORTANT: Beancount cost syntax uses PER-UNIT cost, not total cost.
|
||||||
|
|
@ -381,7 +381,7 @@ def format_expense_entry(
|
||||||
# Build entry metadata
|
# Build entry metadata
|
||||||
entry_meta = {
|
entry_meta = {
|
||||||
"user-id": user_id,
|
"user-id": user_id,
|
||||||
"source": "libra-api",
|
"source": "castle-api",
|
||||||
"entry-id": entry_id
|
"entry-id": entry_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -419,7 +419,7 @@ def format_receivable_entry(
|
||||||
entry_id: Optional[str] = None
|
entry_id: Optional[str] = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Format a receivable entry (user owes libra).
|
Format a receivable entry (user owes castle).
|
||||||
|
|
||||||
Uses price notation (@@ SATS) for BQL-queryable SATS tracking.
|
Uses price notation (@@ SATS) for BQL-queryable SATS tracking.
|
||||||
Generates unique receivable link (^rcv-{entry_id}) for settlement tracking.
|
Generates unique receivable link (^rcv-{entry_id}) for settlement tracking.
|
||||||
|
|
@ -466,7 +466,7 @@ def format_receivable_entry(
|
||||||
|
|
||||||
entry_meta = {
|
entry_meta = {
|
||||||
"user-id": user_id,
|
"user-id": user_id,
|
||||||
"source": "libra-api",
|
"source": "castle-api",
|
||||||
"entry-id": entry_id
|
"entry-id": entry_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -512,7 +512,7 @@ def format_payment_entry(
|
||||||
amount_sats: Amount in satoshis (unsigned)
|
amount_sats: Amount in satoshis (unsigned)
|
||||||
description: Payment description
|
description: Payment description
|
||||||
entry_date: Date of payment
|
entry_date: Date of payment
|
||||||
is_payable: True if libra paying user (payable), False if user paying libra (receivable)
|
is_payable: True if castle paying user (payable), False if user paying castle (receivable)
|
||||||
fiat_currency: Optional fiat currency
|
fiat_currency: Optional fiat currency
|
||||||
fiat_amount: Optional fiat amount (unsigned)
|
fiat_amount: Optional fiat amount (unsigned)
|
||||||
payment_hash: Lightning payment hash
|
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)
|
# 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 fiat_currency and fiat_amount_abs and amount_sats_abs > 0:
|
||||||
if is_payable:
|
if is_payable:
|
||||||
# Libra paying user: DR Payable, CR Lightning
|
# Castle paying user: DR Payable, CR Lightning
|
||||||
postings = [
|
postings = [
|
||||||
format_posting_with_cost(
|
format_posting_with_cost(
|
||||||
account=payable_or_receivable_account,
|
account=payable_or_receivable_account,
|
||||||
|
|
@ -546,7 +546,7 @@ def format_payment_entry(
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
# User paying libra: DR Lightning, CR Receivable
|
# User paying castle: DR Lightning, CR Receivable
|
||||||
postings = [
|
postings = [
|
||||||
format_posting_simple(
|
format_posting_simple(
|
||||||
account=payment_account,
|
account=payment_account,
|
||||||
|
|
@ -633,7 +633,7 @@ def format_fiat_settlement_entry(
|
||||||
amount_sats: Equivalent amount in satoshis
|
amount_sats: Equivalent amount in satoshis
|
||||||
description: Payment description
|
description: Payment description
|
||||||
entry_date: Date of settlement
|
entry_date: Date of settlement
|
||||||
is_payable: True if libra paying user (payable), False if user paying libra (receivable)
|
is_payable: True if castle paying user (payable), False if user paying castle (receivable)
|
||||||
payment_method: Payment method (cash, bank_transfer, check, etc.)
|
payment_method: Payment method (cash, bank_transfer, check, etc.)
|
||||||
reference: Optional reference
|
reference: Optional reference
|
||||||
settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "exp-def456"])
|
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
|
# Build postings using price notation (@@ SATS) for BQL queryability
|
||||||
if is_payable:
|
if is_payable:
|
||||||
# Libra paying user: DR Payable, CR Cash/Bank
|
# Castle paying user: DR Payable, CR Cash/Bank
|
||||||
postings = [
|
postings = [
|
||||||
{
|
{
|
||||||
"account": payable_or_receivable_account,
|
"account": payable_or_receivable_account,
|
||||||
|
|
@ -658,7 +658,7 @@ def format_fiat_settlement_entry(
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
# User paying libra: DR Cash/Bank, CR Receivable
|
# User paying castle: DR Cash/Bank, CR Receivable
|
||||||
postings = [
|
postings = [
|
||||||
{
|
{
|
||||||
"account": payment_account,
|
"account": payment_account,
|
||||||
|
|
@ -804,139 +804,6 @@ 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(
|
def format_revenue_entry(
|
||||||
payment_account: str,
|
payment_account: str,
|
||||||
revenue_account: str,
|
revenue_account: str,
|
||||||
|
|
@ -945,11 +812,10 @@ def format_revenue_entry(
|
||||||
entry_date: date,
|
entry_date: date,
|
||||||
fiat_currency: Optional[str] = None,
|
fiat_currency: Optional[str] = None,
|
||||||
fiat_amount: Optional[Decimal] = None,
|
fiat_amount: Optional[Decimal] = None,
|
||||||
reference: Optional[str] = None,
|
reference: Optional[str] = None
|
||||||
entry_id: Optional[str] = None
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Format a revenue entry (libra receives payment directly).
|
Format a revenue entry (castle receives payment directly).
|
||||||
|
|
||||||
Creates a cleared transaction (flag="*") since payment was received.
|
Creates a cleared transaction (flag="*") since payment was received.
|
||||||
|
|
||||||
|
|
@ -963,8 +829,7 @@ def format_revenue_entry(
|
||||||
entry_date: Date of payment
|
entry_date: Date of payment
|
||||||
fiat_currency: Optional fiat currency
|
fiat_currency: Optional fiat currency
|
||||||
fiat_amount: Optional fiat amount (unsigned)
|
fiat_amount: Optional fiat amount (unsigned)
|
||||||
reference: Optional reference (invoice ID, etc.) — stored as its own link
|
reference: Optional reference
|
||||||
entry_id: Optional unique entry ID (generated if not provided)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Fava API entry dict
|
Fava API entry dict
|
||||||
|
|
@ -980,9 +845,6 @@ def format_revenue_entry(
|
||||||
fiat_amount=Decimal("50.00")
|
fiat_amount=Decimal("50.00")
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
if not entry_id:
|
|
||||||
entry_id = generate_entry_id()
|
|
||||||
|
|
||||||
amount_sats_abs = abs(amount_sats)
|
amount_sats_abs = abs(amount_sats)
|
||||||
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
|
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
|
||||||
|
|
||||||
|
|
@ -1007,13 +869,12 @@ def format_revenue_entry(
|
||||||
|
|
||||||
# Note: created-via is redundant with #revenue-entry tag
|
# Note: created-via is redundant with #revenue-entry tag
|
||||||
entry_meta = {
|
entry_meta = {
|
||||||
"source": "libra-api",
|
"source": "castle-api"
|
||||||
"entry-id": entry_id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
links = []
|
links = []
|
||||||
if reference:
|
if reference:
|
||||||
links.append(sanitize_link(reference))
|
links.append(reference)
|
||||||
|
|
||||||
return format_transaction(
|
return format_transaction(
|
||||||
date_val=entry_date,
|
date_val=entry_date,
|
||||||
|
|
@ -1024,68 +885,3 @@ def format_revenue_entry(
|
||||||
links=links,
|
links=links,
|
||||||
meta=entry_meta
|
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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "Libra",
|
"name": "Castle Accounting",
|
||||||
"short_description": "Double-entry accounting system for collective projects",
|
"short_description": "Double-entry accounting system for collective projects",
|
||||||
"tile": "/libra/static/image/libra.png",
|
"tile": "/castle/static/image/castle.png",
|
||||||
"contributors": [
|
"contributors": [
|
||||||
"Your Name"
|
"Your Name"
|
||||||
],
|
],
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"migration_module": "lnbits.extensions.libra.migrations",
|
"migration_module": "lnbits.extensions.castle.migrations",
|
||||||
"db_name": "ext_libra"
|
"db_name": "ext_castle"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
"""
|
"""
|
||||||
Libra Core Module - Pure accounting logic separated from database operations.
|
Castle Core Module - Pure accounting logic separated from database operations.
|
||||||
|
|
||||||
This module contains the core business logic for double-entry accounting,
|
This module contains the core business logic for double-entry accounting,
|
||||||
following Beancount patterns for clean architecture:
|
following Beancount patterns for clean architecture:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
"""
|
"""
|
||||||
Validation rules for Libra accounting.
|
Validation rules for Castle accounting.
|
||||||
|
|
||||||
Comprehensive validation following Beancount's plugin system approach,
|
Comprehensive validation following Beancount's plugin system approach,
|
||||||
but implemented as simple functions that can be called directly.
|
but implemented as simple functions that can be called directly.
|
||||||
|
|
@ -159,7 +159,7 @@ def validate_receivable_entry(
|
||||||
revenue_account_type: str
|
revenue_account_type: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Validate a receivable entry (user owes libra).
|
Validate a receivable entry (user owes castle).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: User ID
|
user_id: User ID
|
||||||
|
|
|
||||||
90
crud.py
90
crud.py
|
|
@ -14,7 +14,7 @@ from .models import (
|
||||||
AssertionStatus,
|
AssertionStatus,
|
||||||
AssignUserRole,
|
AssignUserRole,
|
||||||
BalanceAssertion,
|
BalanceAssertion,
|
||||||
LibraSettings,
|
CastleSettings,
|
||||||
CreateAccount,
|
CreateAccount,
|
||||||
CreateAccountPermission,
|
CreateAccountPermission,
|
||||||
CreateBalanceAssertion,
|
CreateBalanceAssertion,
|
||||||
|
|
@ -32,7 +32,7 @@ from .models import (
|
||||||
StoredUserWalletSettings,
|
StoredUserWalletSettings,
|
||||||
UpdateRole,
|
UpdateRole,
|
||||||
UserBalance,
|
UserBalance,
|
||||||
UserLibraSettings,
|
UserCastleSettings,
|
||||||
UserEquityStatus,
|
UserEquityStatus,
|
||||||
UserRole,
|
UserRole,
|
||||||
UserWalletSettings,
|
UserWalletSettings,
|
||||||
|
|
@ -49,7 +49,7 @@ from .core.validation import (
|
||||||
validate_payment_entry,
|
validate_payment_entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
db = Database("ext_libra")
|
db = Database("ext_castle")
|
||||||
|
|
||||||
# ===== CACHING =====
|
# ===== CACHING =====
|
||||||
# Cache for account and permission lookups to reduce DB queries
|
# 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.
|
Get or create a user-specific account with hierarchical naming.
|
||||||
|
|
||||||
This function checks if the account exists in Fava/Beancount and creates it
|
This function checks if the account exists in Fava/Beancount and creates it
|
||||||
if it doesn't exist. The account is also registered in Libra's database for
|
if it doesn't exist. The account is also registered in Castle's database for
|
||||||
metadata tracking (permissions, descriptions, etc.).
|
metadata tracking (permissions, descriptions, etc.).
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
@ -214,7 +214,7 @@ async def get_or_create_user_account(
|
||||||
# Generate hierarchical account name
|
# Generate hierarchical account name
|
||||||
account_name = format_hierarchical_account_name(account_type, base_name, user_id)
|
account_name = format_hierarchical_account_name(account_type, base_name, user_id)
|
||||||
|
|
||||||
# Try to find existing account with this hierarchical name in Libra DB
|
# Try to find existing account with this hierarchical name in Castle DB
|
||||||
account = await db.fetchone(
|
account = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM accounts
|
SELECT * FROM accounts
|
||||||
|
|
@ -224,9 +224,9 @@ async def get_or_create_user_account(
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"[ACCOUNT CHECK] User {user_id[:8]}, Account: {account_name}, In Libra DB: {account is not None}")
|
logger.info(f"[ACCOUNT CHECK] User {user_id[:8]}, Account: {account_name}, In Castle DB: {account is not None}")
|
||||||
|
|
||||||
# Always check/create in Fava, even if account exists in Libra DB
|
# Always check/create in Fava, even if account exists in Castle DB
|
||||||
# This ensures Beancount has the Open directive
|
# This ensures Beancount has the Open directive
|
||||||
fava_account_exists = False
|
fava_account_exists = False
|
||||||
if True: # Always check Fava
|
if True: # Always check Fava
|
||||||
|
|
@ -250,13 +250,9 @@ async def get_or_create_user_account(
|
||||||
if not fava_account_exists:
|
if not fava_account_exists:
|
||||||
# Create account in Fava/Beancount via Open directive
|
# Create account in Fava/Beancount via Open directive
|
||||||
logger.info(f"[FAVA CREATE] Creating account in Fava: {account_name}")
|
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(
|
await fava.add_account(
|
||||||
account_name=account_name,
|
account_name=account_name,
|
||||||
currencies=None,
|
currencies=["EUR", "SATS", "USD"], # Support common currencies
|
||||||
metadata={
|
metadata={
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"description": f"User-specific {account_type.value} account"
|
"description": f"User-specific {account_type.value} account"
|
||||||
|
|
@ -266,23 +262,23 @@ async def get_or_create_user_account(
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[FAVA ERROR] Could not check/create account in Fava: {e}", exc_info=True)
|
logger.error(f"[FAVA ERROR] Could not check/create account in Fava: {e}", exc_info=True)
|
||||||
# Continue anyway - account creation in Libra DB is still useful for metadata
|
# Continue anyway - account creation in Castle DB is still useful for metadata
|
||||||
|
|
||||||
# Ensure account exists in Libra DB (sync from Beancount if needed)
|
# Ensure account exists in Castle DB (sync from Beancount if needed)
|
||||||
# This uses the account sync module for consistency
|
# This uses the account sync module for consistency
|
||||||
if not account:
|
if not account:
|
||||||
logger.info(f"[LIBRA DB] Syncing account from Beancount to Libra DB: {account_name}")
|
logger.info(f"[CASTLE DB] Syncing account from Beancount to Castle DB: {account_name}")
|
||||||
from .account_sync import sync_single_account_from_beancount
|
from .account_sync import sync_single_account_from_beancount
|
||||||
|
|
||||||
# Sync from Beancount to Libra DB
|
# Sync from Beancount to Castle DB
|
||||||
created = await sync_single_account_from_beancount(account_name)
|
created = await sync_single_account_from_beancount(account_name)
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
logger.info(f"[LIBRA DB] Account synced from Beancount: {account_name}")
|
logger.info(f"[CASTLE DB] Account synced from Beancount: {account_name}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[LIBRA DB] Failed to sync account from Beancount: {account_name}")
|
logger.warning(f"[CASTLE DB] Failed to sync account from Beancount: {account_name}")
|
||||||
|
|
||||||
# Fetch the account from Libra DB
|
# Fetch the account from Castle DB
|
||||||
account = await db.fetchone(
|
account = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM accounts
|
SELECT * FROM accounts
|
||||||
|
|
@ -293,9 +289,9 @@ async def get_or_create_user_account(
|
||||||
)
|
)
|
||||||
|
|
||||||
if not account:
|
if not account:
|
||||||
logger.error(f"[LIBRA DB] Account still not found after sync: {account_name}")
|
logger.error(f"[CASTLE DB] Account still not found after sync: {account_name}")
|
||||||
# Fallback: create directly in Libra DB if sync failed
|
# Fallback: create directly in Castle DB if sync failed
|
||||||
logger.info(f"[LIBRA DB] Creating account directly in Libra DB: {account_name}")
|
logger.info(f"[CASTLE DB] Creating account directly in Castle DB: {account_name}")
|
||||||
try:
|
try:
|
||||||
account = await create_account(
|
account = await create_account(
|
||||||
CreateAccount(
|
CreateAccount(
|
||||||
|
|
@ -308,7 +304,7 @@ async def get_or_create_user_account(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Handle UNIQUE constraint error - account already exists
|
# Handle UNIQUE constraint error - account already exists
|
||||||
if "UNIQUE constraint failed" in str(e) and "accounts.name" in str(e):
|
if "UNIQUE constraint failed" in str(e) and "accounts.name" in str(e):
|
||||||
logger.warning(f"[LIBRA DB] Account already exists (UNIQUE constraint), fetching by name: {account_name}")
|
logger.warning(f"[CASTLE DB] Account already exists (UNIQUE constraint), fetching by name: {account_name}")
|
||||||
# Fetch existing account by name only (ignore user_id in query)
|
# Fetch existing account by name only (ignore user_id in query)
|
||||||
account = await db.fetchone(
|
account = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
|
|
@ -319,10 +315,10 @@ async def get_or_create_user_account(
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
if account:
|
if account:
|
||||||
logger.info(f"[LIBRA DB] Found existing account: {account_name} (user_id: {account.user_id})")
|
logger.info(f"[CASTLE DB] Found existing account: {account_name} (user_id: {account.user_id})")
|
||||||
# Update user_id if it's NULL or different
|
# Update user_id if it's NULL or different
|
||||||
if account.user_id != user_id:
|
if account.user_id != user_id:
|
||||||
logger.info(f"[LIBRA DB] Updating account user_id from {account.user_id} to {user_id}")
|
logger.info(f"[CASTLE DB] Updating account user_id from {account.user_id} to {user_id}")
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE accounts
|
UPDATE accounts
|
||||||
|
|
@ -344,7 +340,7 @@ async def get_or_create_user_account(
|
||||||
# Re-raise if it's a different error
|
# Re-raise if it's a different error
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
logger.info(f"[LIBRA DB] Account already exists in Libra DB: {account_name}")
|
logger.info(f"[CASTLE DB] Account already exists in Castle DB: {account_name}")
|
||||||
|
|
||||||
return account
|
return account
|
||||||
|
|
||||||
|
|
@ -355,7 +351,7 @@ async def get_or_create_user_account(
|
||||||
# ===== JOURNAL ENTRY OPERATIONS (REMOVED) =====
|
# ===== JOURNAL ENTRY OPERATIONS (REMOVED) =====
|
||||||
#
|
#
|
||||||
# All journal entry operations have been moved to Fava/Beancount.
|
# All journal entry operations have been moved to Fava/Beancount.
|
||||||
# Libra no longer maintains its own journal_entries and entry_lines tables.
|
# Castle no longer maintains its own journal_entries and entry_lines tables.
|
||||||
#
|
#
|
||||||
# For journal entry operations, see:
|
# For journal entry operations, see:
|
||||||
# - views_api.py: api_create_journal_entry() - writes to Fava via FavaClient
|
# - views_api.py: api_create_journal_entry() - writes to Fava via FavaClient
|
||||||
|
|
@ -379,29 +375,29 @@ async def get_or_create_user_account(
|
||||||
# ===== SETTINGS =====
|
# ===== SETTINGS =====
|
||||||
|
|
||||||
|
|
||||||
async def create_libra_settings(
|
async def create_castle_settings(
|
||||||
user_id: str, data: LibraSettings
|
user_id: str, data: CastleSettings
|
||||||
) -> LibraSettings:
|
) -> CastleSettings:
|
||||||
settings = UserLibraSettings(**data.dict(), id=user_id)
|
settings = UserCastleSettings(**data.dict(), id=user_id)
|
||||||
await db.insert("extension_settings", settings)
|
await db.insert("extension_settings", settings)
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
async def get_libra_settings(user_id: str) -> Optional[LibraSettings]:
|
async def get_castle_settings(user_id: str) -> Optional[CastleSettings]:
|
||||||
return await db.fetchone(
|
return await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM extension_settings
|
SELECT * FROM extension_settings
|
||||||
WHERE id = :user_id
|
WHERE id = :user_id
|
||||||
""",
|
""",
|
||||||
{"user_id": user_id},
|
{"user_id": user_id},
|
||||||
LibraSettings,
|
CastleSettings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def update_libra_settings(
|
async def update_castle_settings(
|
||||||
user_id: str, data: LibraSettings
|
user_id: str, data: CastleSettings
|
||||||
) -> LibraSettings:
|
) -> CastleSettings:
|
||||||
settings = UserLibraSettings(**data.dict(), id=user_id)
|
settings = UserCastleSettings(**data.dict(), id=user_id)
|
||||||
await db.update("extension_settings", settings)
|
await db.update("extension_settings", settings)
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
@ -428,26 +424,6 @@ 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(
|
async def update_user_wallet_settings(
|
||||||
user_id: str, data: UserWalletSettings
|
user_id: str, data: UserWalletSettings
|
||||||
) -> UserWalletSettings:
|
) -> UserWalletSettings:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Libra
|
# Castle Accounting
|
||||||
|
|
||||||
A comprehensive double-entry accounting system for collective projects, designed specifically for LNbits.
|
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
|
- **Double-Entry Bookkeeping**: Full accounting system with debits and credits
|
||||||
- **Chart of Accounts**: Pre-configured accounts for Assets, Liabilities, Equity, Revenue, and Expenses
|
- **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:
|
- **User Expense Tracking**: Members can record out-of-pocket expenses as either:
|
||||||
- **Liabilities**: Libra owes them money (reimbursable)
|
- **Liabilities**: Castle owes them money (reimbursable)
|
||||||
- **Equity**: Their contribution to the collective
|
- **Equity**: Their contribution to the collective
|
||||||
- **Accounts Receivable**: Track what users owe the organization (e.g., accommodation fees)
|
- **Accounts Receivable**: Track what users owe the Castle (e.g., accommodation fees)
|
||||||
- **Revenue Tracking**: Record revenue received by the collective
|
- **Revenue Tracking**: Record revenue received by the collective
|
||||||
- **User Balance Dashboard**: Each user sees their balance with the organization
|
- **User Balance Dashboard**: Each user sees their balance with the Castle
|
||||||
- **Lightning Integration**: Generate invoices for outstanding balances
|
- **Lightning Integration**: Generate invoices for outstanding balances
|
||||||
- **Transaction History**: View all accounting entries and transactions
|
- **Transaction History**: View all accounting entries and transactions
|
||||||
|
|
||||||
## Use Cases
|
## Use Cases
|
||||||
|
|
||||||
### 1. User Pays Expense Out of Pocket
|
### 1. User Pays Expense Out of Pocket
|
||||||
When a member buys supplies for the collective:
|
When a member buys supplies for the Castle:
|
||||||
- They can choose to be reimbursed (Liability)
|
- They can choose to be reimbursed (Liability)
|
||||||
- Or contribute it as equity (Equity)
|
- Or contribute it as equity (Equity)
|
||||||
|
|
||||||
### 2. Accounts Receivable
|
### 2. Accounts Receivable
|
||||||
When someone stays with the collective and owes money:
|
When someone stays at the Castle and owes money:
|
||||||
- Admin creates an AR entry (e.g., "5 nights @ 10€/night = 50€")
|
- Admin creates an AR entry (e.g., "5 nights @ 10€/night = 50€")
|
||||||
- User sees they owe 50€ in their dashboard
|
- User sees they owe 50€ in their dashboard
|
||||||
- They can generate an invoice to pay it off
|
- They can generate an invoice to pay it off
|
||||||
|
|
||||||
### 3. Revenue Recording
|
### 3. Revenue Recording
|
||||||
When the organization receives revenue:
|
When the Castle receives revenue:
|
||||||
- Record revenue with the payment method (Cash, Lightning, Bank)
|
- Record revenue with the payment method (Cash, Lightning, Bank)
|
||||||
- Properly categorized in the accounting system
|
- Properly categorized in the accounting system
|
||||||
|
|
||||||
|
|
@ -58,8 +58,8 @@ When the organization receives revenue:
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
1. Enable the Libra extension in LNbits
|
1. Enable the Castle extension in LNbits
|
||||||
2. Visit the Libra page to see your dashboard
|
2. Visit the Castle page to see your dashboard
|
||||||
3. Start tracking expenses and balances!
|
3. Start tracking expenses and balances!
|
||||||
|
|
||||||
The extension automatically creates a default chart of accounts on first run.
|
The extension automatically creates a default chart of accounts on first run.
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Implemented two major improvements for Libra administration:
|
Implemented two major improvements for Castle administration:
|
||||||
|
|
||||||
1. **Account Synchronization** - Automatically sync accounts from Beancount → Libra DB
|
1. **Account Synchronization** - Automatically sync accounts from Beancount → Castle DB
|
||||||
2. **Bulk Permission Management** - Tools for managing permissions at scale
|
2. **Bulk Permission Management** - Tools for managing permissions at scale
|
||||||
|
|
||||||
**Total Implementation Time**: ~4 hours
|
**Total Implementation Time**: ~4 hours
|
||||||
|
|
@ -23,24 +23,24 @@ Implemented two major improvements for Libra administration:
|
||||||
|
|
||||||
### Problem Solved
|
### Problem Solved
|
||||||
|
|
||||||
**Before**: Accounts existed in both Beancount and Libra DB, with manual sync required.
|
**Before**: Accounts existed in both Beancount and Castle DB, with manual sync required.
|
||||||
**After**: Automatic sync keeps Libra DB in sync with Beancount (source of truth).
|
**After**: Automatic sync keeps Castle DB in sync with Beancount (source of truth).
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
**New Module**: `libra/account_sync.py`
|
**New Module**: `castle/account_sync.py`
|
||||||
|
|
||||||
**Core Functions**:
|
**Core Functions**:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# 1. Full sync from Beancount to Libra
|
# 1. Full sync from Beancount to Castle
|
||||||
stats = await sync_accounts_from_beancount(force_full_sync=False)
|
stats = await sync_accounts_from_beancount(force_full_sync=False)
|
||||||
|
|
||||||
# 2. Sync single account
|
# 2. Sync single account
|
||||||
success = await sync_single_account_from_beancount("Expenses:Food")
|
success = await sync_single_account_from_beancount("Expenses:Food")
|
||||||
|
|
||||||
# 3. Ensure account exists (recommended before granting permissions)
|
# 3. Ensure account exists (recommended before granting permissions)
|
||||||
exists = await ensure_account_exists_in_libra("Expenses:Marketing")
|
exists = await ensure_account_exists_in_castle("Expenses:Marketing")
|
||||||
|
|
||||||
# 4. Scheduled background sync (run hourly)
|
# 4. Scheduled background sync (run hourly)
|
||||||
stats = await scheduled_account_sync()
|
stats = await scheduled_account_sync()
|
||||||
|
|
@ -77,7 +77,7 @@ stats = await scheduled_account_sync()
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Sync all accounts from Beancount
|
# Sync all accounts from Beancount
|
||||||
from libra.account_sync import sync_accounts_from_beancount
|
from castle.account_sync import sync_accounts_from_beancount
|
||||||
|
|
||||||
stats = await sync_accounts_from_beancount()
|
stats = await sync_accounts_from_beancount()
|
||||||
|
|
||||||
|
|
@ -96,11 +96,11 @@ Errors: 0
|
||||||
#### Before Granting Permission (Best Practice)
|
#### Before Granting Permission (Best Practice)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from libra.account_sync import ensure_account_exists_in_libra
|
from castle.account_sync import ensure_account_exists_in_castle
|
||||||
from libra.crud import create_account_permission
|
from castle.crud import create_account_permission
|
||||||
|
|
||||||
# Ensure account exists in Libra DB first
|
# Ensure account exists in Castle DB first
|
||||||
account_exists = await ensure_account_exists_in_libra("Expenses:Marketing")
|
account_exists = await ensure_account_exists_in_castle("Expenses:Marketing")
|
||||||
|
|
||||||
if account_exists:
|
if account_exists:
|
||||||
# Now safe to grant permission
|
# Now safe to grant permission
|
||||||
|
|
@ -116,9 +116,9 @@ if account_exists:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Add to your scheduler (cron, APScheduler, etc.)
|
# Add to your scheduler (cron, APScheduler, etc.)
|
||||||
from libra.account_sync import scheduled_account_sync
|
from castle.account_sync import scheduled_account_sync
|
||||||
|
|
||||||
# Run every hour to keep Libra DB in sync
|
# Run every hour to keep Castle DB in sync
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
scheduled_account_sync,
|
scheduled_account_sync,
|
||||||
'interval',
|
'interval',
|
||||||
|
|
@ -142,7 +142,7 @@ Authorization: Bearer {admin_key}
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"total_beancount_accounts": 150,
|
"total_beancount_accounts": 150,
|
||||||
"total_libra_accounts": 150,
|
"total_castle_accounts": 150,
|
||||||
"accounts_added": 2,
|
"accounts_added": 2,
|
||||||
"accounts_updated": 0,
|
"accounts_updated": 0,
|
||||||
"accounts_skipped": 148,
|
"accounts_skipped": 148,
|
||||||
|
|
@ -152,8 +152,8 @@ Authorization: Bearer {admin_key}
|
||||||
|
|
||||||
### Benefits
|
### Benefits
|
||||||
|
|
||||||
1. **Beancount as Source of Truth**: Libra DB automatically reflects Beancount state
|
1. **Beancount as Source of Truth**: Castle DB automatically reflects Beancount state
|
||||||
2. **Reduced Manual Work**: No more manual account creation in Libra
|
2. **Reduced Manual Work**: No more manual account creation in Castle
|
||||||
3. **Prevents Permission Errors**: Cannot grant permission on non-existent account
|
3. **Prevents Permission Errors**: Cannot grant permission on non-existent account
|
||||||
4. **Audit Trail**: Tracks which accounts were synced and when
|
4. **Audit Trail**: Tracks which accounts were synced and when
|
||||||
5. **Safe Operations**: Continues on errors, never deletes accounts
|
5. **Safe Operations**: Continues on errors, never deletes accounts
|
||||||
|
|
@ -169,7 +169,7 @@ Authorization: Bearer {admin_key}
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
**New Module**: `libra/permission_management.py`
|
**New Module**: `castle/permission_management.py`
|
||||||
|
|
||||||
**Core Functions**:
|
**Core Functions**:
|
||||||
|
|
||||||
|
|
@ -471,19 +471,19 @@ print(f"Permission types removed: {result['permission_types_removed']}")
|
||||||
# OLD: Manual permission creation (risky)
|
# OLD: Manual permission creation (risky)
|
||||||
await create_account_permission(
|
await create_account_permission(
|
||||||
user_id="alice",
|
user_id="alice",
|
||||||
account_id="acc123", # What if account doesn't exist in Libra DB?
|
account_id="acc123", # What if account doesn't exist in Castle DB?
|
||||||
permission_type=PermissionType.SUBMIT_EXPENSE,
|
permission_type=PermissionType.SUBMIT_EXPENSE,
|
||||||
granted_by="admin"
|
granted_by="admin"
|
||||||
)
|
)
|
||||||
|
|
||||||
# NEW: Safe permission creation with account sync
|
# NEW: Safe permission creation with account sync
|
||||||
from libra.account_sync import ensure_account_exists_in_libra
|
from castle.account_sync import ensure_account_exists_in_castle
|
||||||
|
|
||||||
# Ensure account exists first
|
# Ensure account exists first
|
||||||
account_exists = await ensure_account_exists_in_libra("Expenses:Marketing")
|
account_exists = await ensure_account_exists_in_castle("Expenses:Marketing")
|
||||||
|
|
||||||
if account_exists:
|
if account_exists:
|
||||||
# Now safe - account guaranteed to be in Libra DB
|
# Now safe - account guaranteed to be in Castle DB
|
||||||
await create_account_permission(
|
await create_account_permission(
|
||||||
user_id="alice",
|
user_id="alice",
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
|
|
@ -497,10 +497,10 @@ else:
|
||||||
### Scheduler Integration
|
### Scheduler Integration
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Add to your Libra extension startup
|
# Add to your Castle extension startup
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from libra.account_sync import scheduled_account_sync
|
from castle.account_sync import scheduled_account_sync
|
||||||
from libra.permission_management import cleanup_expired_permissions
|
from castle.permission_management import cleanup_expired_permissions
|
||||||
|
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
|
|
@ -610,7 +610,7 @@ async def test_copy_permissions():
|
||||||
async def test_onboarding_workflow():
|
async def test_onboarding_workflow():
|
||||||
"""Test complete onboarding workflow"""
|
"""Test complete onboarding workflow"""
|
||||||
# 1. Sync account
|
# 1. Sync account
|
||||||
await ensure_account_exists_in_libra("Expenses:Food")
|
await ensure_account_exists_in_castle("Expenses:Food")
|
||||||
|
|
||||||
# 2. Copy permissions from template user
|
# 2. Copy permissions from template user
|
||||||
result = await copy_permissions(
|
result = await copy_permissions(
|
||||||
|
|
@ -745,19 +745,19 @@ logger.error(f"Account sync error: {error}")
|
||||||
|
|
||||||
## Migration Guide
|
## Migration Guide
|
||||||
|
|
||||||
### For Existing Libra Installations
|
### For Existing Castle Installations
|
||||||
|
|
||||||
**Step 1: Deploy New Modules**
|
**Step 1: Deploy New Modules**
|
||||||
```bash
|
```bash
|
||||||
# Copy new files to Libra extension
|
# Copy new files to Castle extension
|
||||||
cp account_sync.py /path/to/libra/
|
cp account_sync.py /path/to/castle/
|
||||||
cp permission_management.py /path/to/libra/
|
cp permission_management.py /path/to/castle/
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 2: Initial Account Sync**
|
**Step 2: Initial Account Sync**
|
||||||
```python
|
```python
|
||||||
# Run once to sync existing accounts
|
# Run once to sync existing accounts
|
||||||
from libra.account_sync import sync_accounts_from_beancount
|
from castle.account_sync import sync_accounts_from_beancount
|
||||||
|
|
||||||
stats = await sync_accounts_from_beancount(force_full_sync=True)
|
stats = await sync_accounts_from_beancount(force_full_sync=True)
|
||||||
print(f"Synced {stats['accounts_added']} accounts")
|
print(f"Synced {stats['accounts_added']} accounts")
|
||||||
|
|
@ -784,14 +784,14 @@ await bulk_grant_permission(...)
|
||||||
## Documentation Updates
|
## Documentation Updates
|
||||||
|
|
||||||
**New files created**:
|
**New files created**:
|
||||||
- ✅ `libra/account_sync.py` (230 lines)
|
- ✅ `castle/account_sync.py` (230 lines)
|
||||||
- ✅ `libra/permission_management.py` (400 lines)
|
- ✅ `castle/permission_management.py` (400 lines)
|
||||||
- ✅ `docs/PERMISSIONS-SYSTEM.md` (full permission system docs)
|
- ✅ `docs/PERMISSIONS-SYSTEM.md` (full permission system docs)
|
||||||
- ✅ `docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md` (this file)
|
- ✅ `docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md` (this file)
|
||||||
|
|
||||||
**Files to update**:
|
**Files to update**:
|
||||||
- `libra/views_api.py` - Add new admin endpoints
|
- `castle/views_api.py` - Add new admin endpoints
|
||||||
- `libra/README.md` - Document new features
|
- `castle/README.md` - Document new features
|
||||||
- `tests/` - Add comprehensive tests
|
- `tests/` - Add comprehensive tests
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -801,7 +801,7 @@ await bulk_grant_permission(...)
|
||||||
### What Was Built
|
### What Was Built
|
||||||
|
|
||||||
1. **Account Sync Module** (230 lines)
|
1. **Account Sync Module** (230 lines)
|
||||||
- Automatic sync from Beancount → Libra DB
|
- Automatic sync from Beancount → Castle DB
|
||||||
- Type inference and user ID extraction
|
- Type inference and user ID extraction
|
||||||
- Background scheduling support
|
- Background scheduling support
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -195,13 +195,13 @@ id="toc-professional-assessment">Professional Assessment</a></li>
|
||||||
<h1 id="accounting-analysis-net-settlement-entry-pattern">Accounting
|
<h1 id="accounting-analysis-net-settlement-entry-pattern">Accounting
|
||||||
Analysis: Net Settlement Entry Pattern</h1>
|
Analysis: Net Settlement Entry Pattern</h1>
|
||||||
<p><strong>Date</strong>: 2025-01-12 <strong>Prepared By</strong>:
|
<p><strong>Date</strong>: 2025-01-12 <strong>Prepared By</strong>:
|
||||||
Senior Accounting Review <strong>Subject</strong>: Libra Extension -
|
Senior Accounting Review <strong>Subject</strong>: Castle Extension -
|
||||||
Lightning Payment Settlement Entries <strong>Status</strong>: Technical
|
Lightning Payment Settlement Entries <strong>Status</strong>: Technical
|
||||||
Review</p>
|
Review</p>
|
||||||
<hr />
|
<hr />
|
||||||
<h2 id="executive-summary">Executive Summary</h2>
|
<h2 id="executive-summary">Executive Summary</h2>
|
||||||
<p>This document provides a professional accounting assessment of
|
<p>This document provides a professional accounting assessment of
|
||||||
Libra’s net settlement entry pattern used for recording Lightning
|
Castle’s net settlement entry pattern used for recording Lightning
|
||||||
Network payments that settle fiat-denominated receivables. The analysis
|
Network payments that settle fiat-denominated receivables. The analysis
|
||||||
identifies areas where the implementation deviates from traditional
|
identifies areas where the implementation deviates from traditional
|
||||||
accounting best practices and provides specific recommendations for
|
accounting best practices and provides specific recommendations for
|
||||||
|
|
@ -214,7 +214,7 @@ hierarchy</p>
|
||||||
<hr />
|
<hr />
|
||||||
<h2 id="background-the-technical-challenge">Background: The Technical
|
<h2 id="background-the-technical-challenge">Background: The Technical
|
||||||
Challenge</h2>
|
Challenge</h2>
|
||||||
<p>Libra operates as a Lightning Network-integrated accounting system
|
<p>Castle operates as a Lightning Network-integrated accounting system
|
||||||
for collectives (co-living spaces, makerspaces). It faces a unique
|
for collectives (co-living spaces, makerspaces). It faces a unique
|
||||||
accounting challenge:</p>
|
accounting challenge:</p>
|
||||||
<p><strong>Scenario</strong>: User creates a receivable in EUR (e.g.,
|
<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
|
<p><strong>Challenge</strong>: Record the payment while: 1. Clearing the
|
||||||
exact EUR receivable amount 2. Recording the exact satoshi amount
|
exact EUR receivable amount 2. Recording the exact satoshi amount
|
||||||
received 3. Handling cases where users have both receivables (owe
|
received 3. Handling cases where users have both receivables (owe
|
||||||
Libra) and payables (Libra owes them) 4. Maintaining Beancount
|
Castle) and payables (Castle owes them) 4. Maintaining Beancount
|
||||||
double-entry balance</p>
|
double-entry balance</p>
|
||||||
<hr />
|
<hr />
|
||||||
<h2 id="current-implementation">Current Implementation</h2>
|
<h2 id="current-implementation">Current Implementation</h2>
|
||||||
|
|
@ -231,7 +231,7 @@ double-entry balance</p>
|
||||||
<pre class="beancount"><code>; Step 1: Receivable Created
|
<pre class="beancount"><code>; Step 1: Receivable Created
|
||||||
2025-11-12 * "room (200.00 EUR)" #receivable-entry
|
2025-11-12 * "room (200.00 EUR)" #receivable-entry
|
||||||
user-id: "375ec158"
|
user-id: "375ec158"
|
||||||
source: "libra-api"
|
source: "castle-api"
|
||||||
sats-amount: "225033"
|
sats-amount: "225033"
|
||||||
Assets:Receivable:User-375ec158 200.00 EUR
|
Assets:Receivable:User-375ec158 200.00 EUR
|
||||||
sats-equivalent: "225033"
|
sats-equivalent: "225033"
|
||||||
|
|
@ -344,7 +344,7 @@ class="sourceCode sql"><code class="sourceCode sql"><span id="cb8-1"><a href="#c
|
||||||
payment-hash: "8d080ec4..."
|
payment-hash: "8d080ec4..."
|
||||||
Assets:Receivable:User-375ec158 -200.00 EUR
|
Assets:Receivable:User-375ec158 -200.00 EUR
|
||||||
; No sats-equivalent needed here</code></pre>
|
; No sats-equivalent needed here</code></pre>
|
||||||
<p><strong>Option B - Use EUR positions with metadata</strong> (Libra’s
|
<p><strong>Option B - Use EUR positions with metadata</strong> (Castle’s
|
||||||
current approach):</p>
|
current approach):</p>
|
||||||
<pre class="beancount"><code>Assets:Bitcoin:Lightning 200.00 EUR
|
<pre class="beancount"><code>Assets:Bitcoin:Lightning 200.00 EUR
|
||||||
sats-received: "225033"
|
sats-received: "225033"
|
||||||
|
|
@ -452,8 +452,8 @@ OR payable)</li>
|
||||||
(receivable AND payable)</li>
|
(receivable AND payable)</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><strong>When Net Settlement is Appropriate</strong>:</p>
|
<p><strong>When Net Settlement is Appropriate</strong>:</p>
|
||||||
<pre><code>User owes Libra: 555.00 EUR (receivable)
|
<pre><code>User owes Castle: 555.00 EUR (receivable)
|
||||||
Libra owes User: 38.00 EUR (payable)
|
Castle owes User: 38.00 EUR (payable)
|
||||||
Net amount due: 517.00 EUR (true settlement)</code></pre>
|
Net amount due: 517.00 EUR (true settlement)</code></pre>
|
||||||
<p>Proper three-posting entry:</p>
|
<p>Proper three-posting entry:</p>
|
||||||
<pre class="beancount"><code>Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR
|
<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
|
Liabilities:Payable:User 38.00 EUR
|
||||||
; Net: 517.00 = -555.00 + 38.00 ✓</code></pre>
|
; Net: 517.00 = -555.00 + 38.00 ✓</code></pre>
|
||||||
<p><strong>When Two Postings Suffice</strong>:</p>
|
<p><strong>When Two Postings Suffice</strong>:</p>
|
||||||
<pre><code>User owes Libra: 200.00 EUR (receivable)
|
<pre><code>User owes Castle: 200.00 EUR (receivable)
|
||||||
Libra owes User: 0.00 EUR (no payable)
|
Castle owes User: 0.00 EUR (no payable)
|
||||||
Amount due: 200.00 EUR (simple payment)</code></pre>
|
Amount due: 200.00 EUR (simple payment)</code></pre>
|
||||||
<p>Simpler two-posting entry:</p>
|
<p>Simpler two-posting entry:</p>
|
||||||
<pre class="beancount"><code>Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR
|
<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
|
id="approach-3-true-net-settlement-when-both-obligations-exist">Approach
|
||||||
3: True Net Settlement (When Both Obligations Exist)</h3>
|
3: True Net Settlement (When Both Obligations Exist)</h3>
|
||||||
<pre class="beancount"><code>2025-11-12 * "Net settlement via Lightning"
|
<pre class="beancount"><code>2025-11-12 * "Net settlement via Lightning"
|
||||||
; User owes 555 EUR, Libra owes 38 EUR, net: 517 EUR
|
; User owes 555 EUR, Castle owes 38 EUR, net: 517 EUR
|
||||||
Assets:Bitcoin:Lightning 517.00 EUR
|
Assets:Bitcoin:Lightning 517.00 EUR
|
||||||
sats-received: "565251"
|
sats-received: "565251"
|
||||||
Assets:Receivable:User-375ec158 -555.00 EUR
|
Assets:Receivable:User-375ec158 -555.00 EUR
|
||||||
|
|
@ -570,7 +570,7 @@ Method</h4>
|
||||||
<p><strong>Decision Required</strong>: Select either position-based OR
|
<p><strong>Decision Required</strong>: Select either position-based OR
|
||||||
metadata-based satoshi tracking.</p>
|
metadata-based satoshi tracking.</p>
|
||||||
<p><strong>Option A - Keep Metadata Approach</strong> (recommended for
|
<p><strong>Option A - Keep Metadata Approach</strong> (recommended for
|
||||||
Libra):</p>
|
Castle):</p>
|
||||||
<div class="sourceCode" id="cb25"><pre
|
<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>
|
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>
|
<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-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>
|
<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
|
<p><strong>Recommendation</strong>: Choose Option A (metadata) for
|
||||||
consistency with Libra’s architecture.</p>
|
consistency with Castle’s architecture.</p>
|
||||||
<hr />
|
<hr />
|
||||||
<h4 id="rename-function-for-clarity">1.3 Rename Function for
|
<h4 id="rename-function-for-clarity">1.3 Rename Function for
|
||||||
Clarity</h4>
|
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-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-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-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: Libra 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: Castle 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>
|
<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 />
|
<hr />
|
||||||
<h3 id="priority-3-long-term-architectural-decisions">Priority 3:
|
<h3 id="priority-3-long-term-architectural-decisions">Priority 3:
|
||||||
|
|
@ -742,7 +742,7 @@ architectures:</p>
|
||||||
<p><strong>Recommendation</strong>: Architecture A (EUR primary)
|
<p><strong>Recommendation</strong>: Architecture A (EUR primary)
|
||||||
because: 1. Most receivables created in EUR 2. Financial reporting
|
because: 1. Most receivables created in EUR 2. Financial reporting
|
||||||
requirements typically in fiat 3. Tax obligations calculated in fiat 4.
|
requirements typically in fiat 3. Tax obligations calculated in fiat 4.
|
||||||
Aligns with current Libra metadata approach</p>
|
Aligns with current Castle metadata approach</p>
|
||||||
<hr />
|
<hr />
|
||||||
<h4 id="consider-separate-ledger-for-cryptocurrency-holdings">3.2
|
<h4 id="consider-separate-ledger-for-cryptocurrency-holdings">3.2
|
||||||
Consider Separate Ledger for Cryptocurrency Holdings</h4>
|
Consider Separate Ledger for Cryptocurrency Holdings</h4>
|
||||||
|
|
@ -754,7 +754,7 @@ from fiat accounting</p>
|
||||||
Assets:Receivable:User-375ec158 -200.00 EUR</code></pre>
|
Assets:Receivable:User-375ec158 -200.00 EUR</code></pre>
|
||||||
<p><strong>Cryptocurrency Sub-Ledger</strong> (SATS-denominated):</p>
|
<p><strong>Cryptocurrency Sub-Ledger</strong> (SATS-denominated):</p>
|
||||||
<pre class="beancount"><code>2025-11-12 * "Lightning payment received"
|
<pre class="beancount"><code>2025-11-12 * "Lightning payment received"
|
||||||
Assets:Bitcoin:Lightning:Libra 225033 SATS
|
Assets:Bitcoin:Lightning:Castle 225033 SATS
|
||||||
Assets:Bitcoin:Custody:User-375ec 225033 SATS</code></pre>
|
Assets:Bitcoin:Custody:User-375ec 225033 SATS</code></pre>
|
||||||
<p><strong>Benefits</strong>: - ✅ Clean separation of concerns - ✅
|
<p><strong>Benefits</strong>: - ✅ Clean separation of concerns - ✅
|
||||||
Cryptocurrency movements tracked independently - ✅ Fiat accounting
|
Cryptocurrency movements tracked independently - ✅ Fiat accounting
|
||||||
|
|
@ -902,7 +902,7 @@ Entry balances</p>
|
||||||
<p><strong>Is this “best practice” accounting?</strong>
|
<p><strong>Is this “best practice” accounting?</strong>
|
||||||
<strong>No</strong>, this implementation deviates from traditional
|
<strong>No</strong>, this implementation deviates from traditional
|
||||||
accounting standards in several ways.</p>
|
accounting standards in several ways.</p>
|
||||||
<p><strong>Is it acceptable for Libra’s use case?</strong> <strong>Yes,
|
<p><strong>Is it acceptable for Castle’s use case?</strong> <strong>Yes,
|
||||||
with modifications</strong>, it’s a reasonable pragmatic solution for a
|
with modifications</strong>, it’s a reasonable pragmatic solution for a
|
||||||
novel problem (cryptocurrency payments of fiat debts).</p>
|
novel problem (cryptocurrency payments of fiat debts).</p>
|
||||||
<p><strong>Critical improvements needed</strong>: 1. ✅ Remove
|
<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
|
<p><strong>The fundamental challenge</strong>: Traditional accounting
|
||||||
wasn’t designed for this scenario. There is no established “standard”
|
wasn’t designed for this scenario. There is no established “standard”
|
||||||
for recording cryptocurrency payments of fiat-denominated receivables.
|
for recording cryptocurrency payments of fiat-denominated receivables.
|
||||||
Libra’s approach is functional, but should be refined to align better
|
Castle’s approach is functional, but should be refined to align better
|
||||||
with accounting principles where possible.</p>
|
with accounting principles where possible.</p>
|
||||||
<h3 id="next-steps">Next Steps</h3>
|
<h3 id="next-steps">Next Steps</h3>
|
||||||
<ol type="1">
|
<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>ASC 105-10-05</strong>: Substance Over Form</li>
|
||||||
<li><strong>Beancount Documentation</strong>:
|
<li><strong>Beancount Documentation</strong>:
|
||||||
http://furius.ca/beancount/doc/index</li>
|
http://furius.ca/beancount/doc/index</li>
|
||||||
<li><strong>Libra Extension</strong>:
|
<li><strong>Castle Extension</strong>:
|
||||||
<code>docs/SATS-EQUIVALENT-METADATA.md</code></li>
|
<code>docs/SATS-EQUIVALENT-METADATA.md</code></li>
|
||||||
<li><strong>BQL Analysis</strong>:
|
<li><strong>BQL Analysis</strong>:
|
||||||
<code>docs/BQL-BALANCE-QUERIES.md</code></li>
|
<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
|
<p><em>This analysis was prepared for internal review and development
|
||||||
planning. It represents a professional accounting assessment of the
|
planning. It represents a professional accounting assessment of the
|
||||||
current implementation and should be used to guide improvements to
|
current implementation and should be used to guide improvements to
|
||||||
Libra’s payment recording system.</em></p>
|
Castle’s payment recording system.</em></p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,14 @@
|
||||||
|
|
||||||
**Date**: 2025-01-12
|
**Date**: 2025-01-12
|
||||||
**Prepared By**: Senior Accounting Review
|
**Prepared By**: Senior Accounting Review
|
||||||
**Subject**: Libra Extension - Lightning Payment Settlement Entries
|
**Subject**: Castle Extension - Lightning Payment Settlement Entries
|
||||||
**Status**: Technical Review
|
**Status**: Technical Review
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
**Key Findings**:
|
**Key Findings**:
|
||||||
- ✅ Double-entry integrity maintained
|
- ✅ Double-entry integrity maintained
|
||||||
|
|
@ -23,14 +23,14 @@ This document provides a professional accounting assessment of Libra's net settl
|
||||||
|
|
||||||
## Background: The Technical Challenge
|
## Background: The Technical Challenge
|
||||||
|
|
||||||
Libra operates as a Lightning Network-integrated accounting system for collectives (co-living spaces, makerspaces). It faces a unique accounting challenge:
|
Castle 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).
|
**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:
|
**Challenge**: Record the payment while:
|
||||||
1. Clearing the exact EUR receivable amount
|
1. Clearing the exact EUR receivable amount
|
||||||
2. Recording the exact satoshi amount received
|
2. Recording the exact satoshi amount received
|
||||||
3. Handling cases where users have both receivables (owe Libra) and payables (Libra owes them)
|
3. Handling cases where users have both receivables (owe Castle) and payables (Castle owes them)
|
||||||
4. Maintaining Beancount double-entry balance
|
4. Maintaining Beancount double-entry balance
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -43,7 +43,7 @@ Libra operates as a Lightning Network-integrated accounting system for collectiv
|
||||||
; Step 1: Receivable Created
|
; Step 1: Receivable Created
|
||||||
2025-11-12 * "room (200.00 EUR)" #receivable-entry
|
2025-11-12 * "room (200.00 EUR)" #receivable-entry
|
||||||
user-id: "375ec158"
|
user-id: "375ec158"
|
||||||
source: "libra-api"
|
source: "castle-api"
|
||||||
sats-amount: "225033"
|
sats-amount: "225033"
|
||||||
Assets:Receivable:User-375ec158 200.00 EUR
|
Assets:Receivable:User-375ec158 200.00 EUR
|
||||||
sats-equivalent: "225033"
|
sats-equivalent: "225033"
|
||||||
|
|
@ -187,7 +187,7 @@ Assets:Receivable:User-375ec158 -200.00 EUR
|
||||||
; No sats-equivalent needed here
|
; No sats-equivalent needed here
|
||||||
```
|
```
|
||||||
|
|
||||||
**Option B - Use EUR positions with metadata** (Libra's current approach):
|
**Option B - Use EUR positions with metadata** (Castle's current approach):
|
||||||
```beancount
|
```beancount
|
||||||
Assets:Bitcoin:Lightning 200.00 EUR
|
Assets:Bitcoin:Lightning 200.00 EUR
|
||||||
sats-received: "225033"
|
sats-received: "225033"
|
||||||
|
|
@ -314,8 +314,8 @@ Assets:Receivable:User-375ec158 -200.00 EUR
|
||||||
**When Net Settlement is Appropriate**:
|
**When Net Settlement is Appropriate**:
|
||||||
|
|
||||||
```
|
```
|
||||||
User owes Libra: 555.00 EUR (receivable)
|
User owes Castle: 555.00 EUR (receivable)
|
||||||
Libra owes User: 38.00 EUR (payable)
|
Castle owes User: 38.00 EUR (payable)
|
||||||
Net amount due: 517.00 EUR (true settlement)
|
Net amount due: 517.00 EUR (true settlement)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -330,8 +330,8 @@ Liabilities:Payable:User 38.00 EUR
|
||||||
**When Two Postings Suffice**:
|
**When Two Postings Suffice**:
|
||||||
|
|
||||||
```
|
```
|
||||||
User owes Libra: 200.00 EUR (receivable)
|
User owes Castle: 200.00 EUR (receivable)
|
||||||
Libra owes User: 0.00 EUR (no payable)
|
Castle owes User: 0.00 EUR (no payable)
|
||||||
Amount due: 200.00 EUR (simple payment)
|
Amount due: 200.00 EUR (simple payment)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -405,7 +405,7 @@ Assets:Receivable:User -200.00 EUR
|
||||||
|
|
||||||
```beancount
|
```beancount
|
||||||
2025-11-12 * "Net settlement via Lightning"
|
2025-11-12 * "Net settlement via Lightning"
|
||||||
; User owes 555 EUR, Libra owes 38 EUR, net: 517 EUR
|
; User owes 555 EUR, Castle owes 38 EUR, net: 517 EUR
|
||||||
Assets:Bitcoin:Lightning 517.00 EUR
|
Assets:Bitcoin:Lightning 517.00 EUR
|
||||||
sats-received: "565251"
|
sats-received: "565251"
|
||||||
Assets:Receivable:User-375ec158 -555.00 EUR
|
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.
|
**Decision Required**: Select either position-based OR metadata-based satoshi tracking.
|
||||||
|
|
||||||
**Option A - Keep Metadata Approach** (recommended for Libra):
|
**Option A - Keep Metadata Approach** (recommended for Castle):
|
||||||
```python
|
```python
|
||||||
# In format_net_settlement_entry()
|
# In format_net_settlement_entry()
|
||||||
postings = [
|
postings = [
|
||||||
|
|
@ -506,7 +506,7 @@ postings = [
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Recommendation**: Choose Option A (metadata) for consistency with Libra's architecture.
|
**Recommendation**: Choose Option A (metadata) for consistency with Castle's architecture.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -625,7 +625,7 @@ async def create_payment_entry(
|
||||||
payment_hash=payment_hash
|
payment_hash=payment_hash
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# PAYABLE PAYMENT: Libra paying user (different flow)
|
# PAYABLE PAYMENT: Castle paying user (different flow)
|
||||||
return await format_payable_payment_entry(...)
|
return await format_payable_payment_entry(...)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -663,7 +663,7 @@ async def create_payment_entry(
|
||||||
1. Most receivables created in EUR
|
1. Most receivables created in EUR
|
||||||
2. Financial reporting requirements typically in fiat
|
2. Financial reporting requirements typically in fiat
|
||||||
3. Tax obligations calculated in fiat
|
3. Tax obligations calculated in fiat
|
||||||
4. Aligns with current Libra metadata approach
|
4. Aligns with current Castle metadata approach
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -681,7 +681,7 @@ async def create_payment_entry(
|
||||||
**Cryptocurrency Sub-Ledger** (SATS-denominated):
|
**Cryptocurrency Sub-Ledger** (SATS-denominated):
|
||||||
```beancount
|
```beancount
|
||||||
2025-11-12 * "Lightning payment received"
|
2025-11-12 * "Lightning payment received"
|
||||||
Assets:Bitcoin:Lightning:Libra 225033 SATS
|
Assets:Bitcoin:Lightning:Castle 225033 SATS
|
||||||
Assets:Bitcoin:Custody:User-375ec 225033 SATS
|
Assets:Bitcoin:Custody:User-375ec 225033 SATS
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -821,7 +821,7 @@ async def create_payment_entry(
|
||||||
**Is this "best practice" accounting?**
|
**Is this "best practice" accounting?**
|
||||||
**No**, this implementation deviates from traditional accounting standards in several ways.
|
**No**, this implementation deviates from traditional accounting standards in several ways.
|
||||||
|
|
||||||
**Is it acceptable for Libra's use case?**
|
**Is it acceptable for Castle's use case?**
|
||||||
**Yes, with modifications**, it's a reasonable pragmatic solution for a novel problem (cryptocurrency payments of fiat debts).
|
**Yes, with modifications**, it's a reasonable pragmatic solution for a novel problem (cryptocurrency payments of fiat debts).
|
||||||
|
|
||||||
**Critical improvements needed**:
|
**Critical improvements needed**:
|
||||||
|
|
@ -829,7 +829,7 @@ async def create_payment_entry(
|
||||||
2. ✅ Implement exchange gain/loss tracking (required for compliance)
|
2. ✅ Implement exchange gain/loss tracking (required for compliance)
|
||||||
3. ✅ Separate payment vs. settlement logic (accuracy and clarity)
|
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. Libra'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. Castle's approach is functional, but should be refined to align better with accounting principles where possible.
|
||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
|
|
||||||
|
|
@ -847,7 +847,7 @@ async def create_payment_entry(
|
||||||
- **FASB Concept Statement No. 2**: Qualitative Characteristics of Accounting Information
|
- **FASB Concept Statement No. 2**: Qualitative Characteristics of Accounting Information
|
||||||
- **ASC 105-10-05**: Substance Over Form
|
- **ASC 105-10-05**: Substance Over Form
|
||||||
- **Beancount Documentation**: http://furius.ca/beancount/doc/index
|
- **Beancount Documentation**: http://furius.ca/beancount/doc/index
|
||||||
- **Libra Extension**: `docs/SATS-EQUIVALENT-METADATA.md`
|
- **Castle Extension**: `docs/SATS-EQUIVALENT-METADATA.md`
|
||||||
- **BQL Analysis**: `docs/BQL-BALANCE-QUERIES.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 Libra'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 Castle's payment recording system.*
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Beancount Patterns Analysis for Libra Extension
|
# Beancount Patterns Analysis for Castle Extension
|
||||||
|
|
||||||
## Overview
|
## 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 Libra 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 Castle Accounting extension.
|
||||||
|
|
||||||
## Key Patterns to Adopt
|
## Key Patterns to Adopt
|
||||||
|
|
||||||
|
|
@ -38,7 +38,7 @@ class Posting(NamedTuple):
|
||||||
- More memory efficient than regular classes
|
- More memory efficient than regular classes
|
||||||
- Thread-safe by design
|
- Thread-safe by design
|
||||||
|
|
||||||
**Libra Application:**
|
**Castle Application:**
|
||||||
```python
|
```python
|
||||||
# In models.py
|
# In models.py
|
||||||
from typing import NamedTuple, Optional
|
from typing import NamedTuple, Optional
|
||||||
|
|
@ -109,15 +109,15 @@ def validate_commodity_directives(entries, options_map, config):
|
||||||
return entries, errors
|
return entries, errors
|
||||||
```
|
```
|
||||||
|
|
||||||
**Libra Application:**
|
**Castle Application:**
|
||||||
```python
|
```python
|
||||||
# Create plugins/ directory
|
# Create plugins/ directory
|
||||||
# lnbits/extensions/libra/plugins/__init__.py
|
# lnbits/extensions/castle/plugins/__init__.py
|
||||||
|
|
||||||
from typing import Protocol, Tuple, List, Any
|
from typing import Protocol, Tuple, List, Any
|
||||||
|
|
||||||
class LibraPlugin(Protocol):
|
class CastlePlugin(Protocol):
|
||||||
"""Protocol for Libra plugins"""
|
"""Protocol for Castle plugins"""
|
||||||
|
|
||||||
def __call__(
|
def __call__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -130,7 +130,7 @@ class LibraPlugin(Protocol):
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
entries: Journal entries to process
|
entries: Journal entries to process
|
||||||
settings: Libra settings
|
settings: Castle settings
|
||||||
config: Plugin-specific configuration
|
config: Plugin-specific configuration
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -212,7 +212,7 @@ class PluginManager:
|
||||||
if plugin_file.name.startswith('_'):
|
if plugin_file.name.startswith('_'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
module_name = f"libra.plugins.{plugin_file.stem}"
|
module_name = f"castle.plugins.{plugin_file.stem}"
|
||||||
module = importlib.import_module(module_name)
|
module = importlib.import_module(module_name)
|
||||||
|
|
||||||
if hasattr(module, '__plugins__'):
|
if hasattr(module, '__plugins__'):
|
||||||
|
|
@ -276,7 +276,7 @@ class Inventory(dict[tuple[str, Optional[Cost]], Position]):
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Libra Application:**
|
**Castle Application:**
|
||||||
```python
|
```python
|
||||||
# core/inventory.py
|
# core/inventory.py
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
@ -284,8 +284,8 @@ from typing import Optional, Dict, Tuple
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LibraPosition:
|
class CastlePosition:
|
||||||
"""A position in the Libra inventory"""
|
"""A position in the Castle inventory"""
|
||||||
currency: str # "SATS", "EUR", "USD"
|
currency: str # "SATS", "EUR", "USD"
|
||||||
amount: Decimal
|
amount: Decimal
|
||||||
cost_currency: Optional[str] = None # Original currency if converted
|
cost_currency: Optional[str] = None # Original currency if converted
|
||||||
|
|
@ -293,22 +293,22 @@ class LibraPosition:
|
||||||
date: Optional[datetime] = None
|
date: Optional[datetime] = None
|
||||||
metadata: Dict[str, Any] = None
|
metadata: Dict[str, Any] = None
|
||||||
|
|
||||||
class LibraInventory:
|
class CastleInventory:
|
||||||
"""
|
"""
|
||||||
Track user balances across multiple currencies with conversion tracking.
|
Track user balances across multiple currencies with conversion tracking.
|
||||||
Similar to Beancount's Inventory but optimized for Libra's use case.
|
Similar to Beancount's Inventory but optimized for Castle's use case.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.positions: Dict[Tuple[str, Optional[str]], LibraPosition] = {}
|
self.positions: Dict[Tuple[str, Optional[str]], CastlePosition] = {}
|
||||||
|
|
||||||
def add_position(self, position: LibraPosition):
|
def add_position(self, position: CastlePosition):
|
||||||
"""Add or merge a position"""
|
"""Add or merge a position"""
|
||||||
key = (position.currency, position.cost_currency)
|
key = (position.currency, position.cost_currency)
|
||||||
|
|
||||||
if key in self.positions:
|
if key in self.positions:
|
||||||
existing = self.positions[key]
|
existing = self.positions[key]
|
||||||
self.positions[key] = LibraPosition(
|
self.positions[key] = CastlePosition(
|
||||||
currency=position.currency,
|
currency=position.currency,
|
||||||
amount=existing.amount + position.amount,
|
amount=existing.amount + position.amount,
|
||||||
cost_currency=position.cost_currency,
|
cost_currency=position.cost_currency,
|
||||||
|
|
@ -353,9 +353,9 @@ class LibraInventory:
|
||||||
}
|
}
|
||||||
|
|
||||||
# Usage in balance calculation:
|
# Usage in balance calculation:
|
||||||
async def get_user_inventory(user_id: str) -> LibraInventory:
|
async def get_user_inventory(user_id: str) -> CastleInventory:
|
||||||
"""Calculate user's inventory from journal entries"""
|
"""Calculate user's inventory from journal entries"""
|
||||||
inventory = LibraInventory()
|
inventory = CastleInventory()
|
||||||
|
|
||||||
user_accounts = await get_user_accounts(user_id)
|
user_accounts = await get_user_accounts(user_id)
|
||||||
for account in user_accounts:
|
for account in user_accounts:
|
||||||
|
|
@ -369,7 +369,7 @@ async def get_user_inventory(user_id: str) -> LibraInventory:
|
||||||
# Beancount-style: positive = debit, negative = credit
|
# Beancount-style: positive = debit, negative = credit
|
||||||
# Adjust sign for cost amount based on amount direction
|
# Adjust sign for cost amount based on amount direction
|
||||||
cost_sign = 1 if line.amount > 0 else -1
|
cost_sign = 1 if line.amount > 0 else -1
|
||||||
inventory.add_position(LibraPosition(
|
inventory.add_position(CastlePosition(
|
||||||
currency="SATS",
|
currency="SATS",
|
||||||
amount=Decimal(line.amount),
|
amount=Decimal(line.amount),
|
||||||
cost_currency=metadata.get("fiat_currency"),
|
cost_currency=metadata.get("fiat_currency"),
|
||||||
|
|
@ -397,7 +397,7 @@ Every directive has a `meta: dict[str, Any]` attribute that stores:
|
||||||
- `lineno`: Line number
|
- `lineno`: Line number
|
||||||
- Custom metadata like tags, links, notes
|
- Custom metadata like tags, links, notes
|
||||||
|
|
||||||
**Libra Application:**
|
**Castle Application:**
|
||||||
```python
|
```python
|
||||||
class JournalEntryMeta(BaseModel):
|
class JournalEntryMeta(BaseModel):
|
||||||
"""Metadata for journal entries"""
|
"""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.
|
This asserts that the account balance should be exactly 268,548 sats on that date. If it's not, Beancount throws an error.
|
||||||
|
|
||||||
**Libra Application:**
|
**Castle Application:**
|
||||||
```python
|
```python
|
||||||
# models.py
|
# models.py
|
||||||
class BalanceAssertion(BaseModel):
|
class BalanceAssertion(BaseModel):
|
||||||
|
|
@ -464,7 +464,7 @@ class BalanceAssertion(BaseModel):
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
# API endpoint
|
# API endpoint
|
||||||
@libra_api_router.post("/api/v1/assertions/balance")
|
@castle_api_router.post("/api/v1/assertions/balance")
|
||||||
async def create_balance_assertion(
|
async def create_balance_assertion(
|
||||||
data: CreateBalanceAssertion,
|
data: CreateBalanceAssertion,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
|
|
@ -554,7 +554,7 @@ Liabilities:US:CreditCard:Amex
|
||||||
|
|
||||||
Accounts are organized hierarchically with `:` separator.
|
Accounts are organized hierarchically with `:` separator.
|
||||||
|
|
||||||
**Libra Application:**
|
**Castle Application:**
|
||||||
```python
|
```python
|
||||||
# Currently: "Accounts Receivable - af983632"
|
# Currently: "Accounts Receivable - af983632"
|
||||||
# Better: "Assets:Receivable:User-af983632"
|
# Better: "Assets:Receivable:User-af983632"
|
||||||
|
|
@ -617,7 +617,7 @@ def format_account_name(
|
||||||
|
|
||||||
Flags: `*` = cleared, `!` = pending, `#` = flagged for review
|
Flags: `*` = cleared, `!` = pending, `#` = flagged for review
|
||||||
|
|
||||||
**Libra Application:**
|
**Castle Application:**
|
||||||
```python
|
```python
|
||||||
# Add flag field to journal_entries
|
# Add flag field to journal_entries
|
||||||
class JournalEntryFlag(str, Enum):
|
class JournalEntryFlag(str, Enum):
|
||||||
|
|
@ -661,7 +661,7 @@ from decimal import Decimal
|
||||||
amount = Decimal("19.99")
|
amount = Decimal("19.99")
|
||||||
```
|
```
|
||||||
|
|
||||||
**Libra Current Issue:**
|
**Castle Current Issue:**
|
||||||
We're using `int` for satoshis (good!) but `float` for fiat amounts (bad!).
|
We're using `int` for satoshis (good!) but `float` for fiat amounts (bad!).
|
||||||
|
|
||||||
**Fix:**
|
**Fix:**
|
||||||
|
|
@ -709,10 +709,10 @@ WHERE account = 'Assets:Checking'
|
||||||
AND date >= 2025-01-01;
|
AND date >= 2025-01-01;
|
||||||
```
|
```
|
||||||
|
|
||||||
**Libra Application (Future):**
|
**Castle Application (Future):**
|
||||||
```python
|
```python
|
||||||
# Add query endpoint
|
# Add query endpoint
|
||||||
@libra_api_router.post("/api/v1/query")
|
@castle_api_router.post("/api/v1/query")
|
||||||
async def execute_query(
|
async def execute_query(
|
||||||
query: str,
|
query: str,
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
|
|
@ -756,12 +756,12 @@ beancount/
|
||||||
tools/ # Reporting and analysis
|
tools/ # Reporting and analysis
|
||||||
```
|
```
|
||||||
|
|
||||||
**Libra Should Adopt:**
|
**Castle Should Adopt:**
|
||||||
```
|
```
|
||||||
libra/
|
castle/
|
||||||
core/ # NEW: Pure accounting logic
|
core/ # NEW: Pure accounting logic
|
||||||
__init__.py
|
__init__.py
|
||||||
inventory.py # LibraInventory for position tracking
|
inventory.py # CastleInventory for position tracking
|
||||||
balance.py # Balance calculation logic
|
balance.py # Balance calculation logic
|
||||||
validation.py # Entry validation (debits=credits, etc)
|
validation.py # Entry validation (debits=credits, etc)
|
||||||
account.py # Account hierarchy and naming
|
account.py # Account hierarchy and naming
|
||||||
|
|
@ -805,11 +805,11 @@ def validate_entries(entries):
|
||||||
return errors
|
return errors
|
||||||
```
|
```
|
||||||
|
|
||||||
**Libra Application:**
|
**Castle Application:**
|
||||||
```python
|
```python
|
||||||
from typing import NamedTuple, Optional
|
from typing import NamedTuple, Optional
|
||||||
|
|
||||||
class LibraError(NamedTuple):
|
class CastleError(NamedTuple):
|
||||||
"""Base error type"""
|
"""Base error type"""
|
||||||
source: dict # {'endpoint': '...', 'user_id': '...'}
|
source: dict # {'endpoint': '...', 'user_id': '...'}
|
||||||
message: str
|
message: str
|
||||||
|
|
@ -828,7 +828,7 @@ class UnbalancedEntryError(NamedTuple):
|
||||||
difference: int
|
difference: int
|
||||||
|
|
||||||
# Return errors from validation
|
# Return errors from validation
|
||||||
async def validate_journal_entry(entry: CreateJournalEntry) -> list[LibraError]:
|
async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]:
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
# Beancount-style: sum of amounts must equal 0
|
# Beancount-style: sum of amounts must equal 0
|
||||||
|
|
@ -870,7 +870,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[LibraError]:
|
||||||
|
|
||||||
### Phase 3: Core Logic Refactoring (Medium Priority) ✅ COMPLETE
|
### Phase 3: Core Logic Refactoring (Medium Priority) ✅ COMPLETE
|
||||||
9. ✅ Create `core/` module with pure accounting logic
|
9. ✅ Create `core/` module with pure accounting logic
|
||||||
10. ✅ Implement `LibraInventory` for position tracking
|
10. ✅ Implement `CastleInventory` for position tracking
|
||||||
11. ✅ Move balance calculation to `core/balance.py`
|
11. ✅ Move balance calculation to `core/balance.py`
|
||||||
12. ✅ Add comprehensive validation in `core/validation.py`
|
12. ✅ Add comprehensive validation in `core/validation.py`
|
||||||
|
|
||||||
|
|
@ -900,7 +900,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[LibraError]:
|
||||||
7. ✅ Separation of core logic from I/O
|
7. ✅ Separation of core logic from I/O
|
||||||
8. ✅ Comprehensive validation
|
8. ✅ Comprehensive validation
|
||||||
|
|
||||||
**What Libra Should Adopt First:**
|
**What Castle Should Adopt First:**
|
||||||
1. **Decimal for fiat amounts** (prevent rounding errors)
|
1. **Decimal for fiat amounts** (prevent rounding errors)
|
||||||
2. **Meta field** (audit trail, source tracking)
|
2. **Meta field** (audit trail, source tracking)
|
||||||
3. **Flag field** (transaction status)
|
3. **Flag field** (transaction status)
|
||||||
|
|
@ -916,7 +916,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[LibraError]:
|
||||||
|
|
||||||
## Conclusion
|
## Conclusion
|
||||||
|
|
||||||
Beancount's architecture is battle-tested for serious accounting work. By adopting these patterns, Libra can:
|
Beancount's architecture is battle-tested for serious accounting work. By adopting these patterns, Castle can:
|
||||||
- Prevent financial calculation errors (Decimal)
|
- Prevent financial calculation errors (Decimal)
|
||||||
- Support complex workflows (plugins)
|
- Support complex workflows (plugins)
|
||||||
- Build user trust (balance assertions, audit trail)
|
- Build user trust (balance assertions, audit trail)
|
||||||
|
|
|
||||||
|
|
@ -496,7 +496,7 @@ Improvement: 5-10x faster
|
||||||
## Test Results and Findings
|
## Test Results and Findings
|
||||||
|
|
||||||
**Date**: November 10, 2025
|
**Date**: November 10, 2025
|
||||||
**Status**: ⚠️ **NOT FEASIBLE for Libra's Current Data Structure**
|
**Status**: ⚠️ **NOT FEASIBLE for Castle's Current Data Structure**
|
||||||
|
|
||||||
### Implementation Completed
|
### Implementation Completed
|
||||||
|
|
||||||
|
|
@ -523,7 +523,7 @@ Improvement: 5-10x faster
|
||||||
|
|
||||||
### Root Cause: Architecture Limitation
|
### Root Cause: Architecture Limitation
|
||||||
|
|
||||||
**Current Libra Ledger Structure:**
|
**Current Castle Ledger Structure:**
|
||||||
```
|
```
|
||||||
Posting format:
|
Posting format:
|
||||||
Amount: -360.00 EUR ← Position (BQL can query this)
|
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
|
### Why Manual Aggregation is Necessary
|
||||||
|
|
||||||
1. **SATS are Libra's primary currency** for balance tracking
|
1. **SATS are Castle's primary currency** for balance tracking
|
||||||
2. **SATS values are in metadata**, not positions
|
2. **SATS values are in metadata**, not positions
|
||||||
3. **BQL has no metadata query capability**
|
3. **BQL has no metadata query capability**
|
||||||
4. **Must iterate through postings** to read `meta["sats-equivalent"]`
|
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
|
## Future Consideration: Ledger Format Change
|
||||||
|
|
||||||
**If** Libra's ledger format changes to use SATS as position amounts:
|
**If** Castle's ledger format changes to use SATS as position amounts:
|
||||||
|
|
||||||
```beancount
|
```beancount
|
||||||
; Current format (EUR position, SATS in metadata):
|
; Current format (EUR position, SATS in metadata):
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ Expenses:Food -360.00 EUR @@ 337096 SATS
|
||||||
**Total calculation**: Exact 337,096 SATS (no rounding)
|
**Total calculation**: Exact 337,096 SATS (no rounding)
|
||||||
**Precision**: Preserves exact SATS amount from original calculation
|
**Precision**: Preserves exact SATS amount from original calculation
|
||||||
|
|
||||||
**Why `@@` is better for Libra:**
|
**Why `@@` is better for Castle:**
|
||||||
- ✅ Preserves exact SATS amount (no rounding errors)
|
- ✅ Preserves exact SATS amount (no rounding errors)
|
||||||
- ✅ Matches current metadata storage exactly
|
- ✅ Matches current metadata storage exactly
|
||||||
- ✅ Clearer intent: "this transaction equals X SATS total"
|
- ✅ Clearer intent: "this transaction equals X SATS total"
|
||||||
|
|
@ -124,7 +124,7 @@ GROUP BY account;
|
||||||
### Step 1: Run Metadata Test
|
### Step 1: Run Metadata Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/padreug/projects/libra-beancounter
|
cd /home/padreug/projects/castle-beancounter
|
||||||
./test_metadata_simple.sh
|
./test_metadata_simple.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -166,7 +166,7 @@ Add one test entry to your ledger:
|
||||||
|
|
||||||
Then query:
|
Then query:
|
||||||
```bash
|
```bash
|
||||||
curl -s "http://localhost:3333/libra-ledger/api/query" \
|
curl -s "http://localhost:3333/castle-ledger/api/query" \
|
||||||
-G \
|
-G \
|
||||||
--data-urlencode "query_string=SELECT account, position, price_number, price_currency WHERE account ~ 'TEST'" \
|
--data-urlencode "query_string=SELECT account, position, price_number, price_currency WHERE account ~ 'TEST'" \
|
||||||
| jq '.'
|
| jq '.'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Automated Daily Reconciliation
|
# Automated Daily Reconciliation
|
||||||
|
|
||||||
The Libra extension includes automated daily balance checking to ensure accounting accuracy.
|
The Castle extension includes automated daily balance checking to ensure accounting accuracy.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@ You can manually trigger the reconciliation check from the UI or via API:
|
||||||
|
|
||||||
### Via API
|
### Via API
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://your-lnbits-instance.com/libra/api/v1/tasks/daily-reconciliation \
|
curl -X POST https://your-lnbits-instance.com/castle/api/v1/tasks/daily-reconciliation \
|
||||||
-H "X-Api-Key: YOUR_ADMIN_KEY"
|
-H "X-Api-Key: YOUR_ADMIN_KEY"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ Add to your crontab:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run daily at 2 AM
|
# Run daily at 2 AM
|
||||||
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
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
To edit crontab:
|
To edit crontab:
|
||||||
|
|
@ -38,22 +38,22 @@ crontab -e
|
||||||
|
|
||||||
### Option 2: Systemd Timer
|
### Option 2: Systemd Timer
|
||||||
|
|
||||||
Create `/etc/systemd/system/libra-reconciliation.service`:
|
Create `/etc/systemd/system/castle-reconciliation.service`:
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Libra Daily Reconciliation Check
|
Description=Castle Daily Reconciliation Check
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
User=lnbits
|
User=lnbits
|
||||||
ExecStart=/usr/bin/curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
|
ExecStart=/usr/bin/curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
|
||||||
```
|
```
|
||||||
|
|
||||||
Create `/etc/systemd/system/libra-reconciliation.timer`:
|
Create `/etc/systemd/system/castle-reconciliation.timer`:
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Run Libra reconciliation daily
|
Description=Run Castle reconciliation daily
|
||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
OnCalendar=daily
|
OnCalendar=daily
|
||||||
|
|
@ -66,8 +66,8 @@ WantedBy=timers.target
|
||||||
|
|
||||||
Enable and start:
|
Enable and start:
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl enable libra-reconciliation.timer
|
sudo systemctl enable castle-reconciliation.timer
|
||||||
sudo systemctl start libra-reconciliation.timer
|
sudo systemctl start castle-reconciliation.timer
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 3: Docker/Kubernetes CronJob
|
### Option 3: Docker/Kubernetes CronJob
|
||||||
|
|
@ -78,7 +78,7 @@ For containerized deployments:
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
kind: CronJob
|
kind: CronJob
|
||||||
metadata:
|
metadata:
|
||||||
name: libra-reconciliation
|
name: castle-reconciliation
|
||||||
spec:
|
spec:
|
||||||
schedule: "0 2 * * *" # Daily at 2 AM
|
schedule: "0 2 * * *" # Daily at 2 AM
|
||||||
jobTemplate:
|
jobTemplate:
|
||||||
|
|
@ -91,7 +91,7 @@ spec:
|
||||||
args:
|
args:
|
||||||
- /bin/sh
|
- /bin/sh
|
||||||
- -c
|
- -c
|
||||||
- curl -X POST http://lnbits:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ${ADMIN_KEY}"
|
- curl -X POST http://lnbits:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ${ADMIN_KEY}"
|
||||||
restartPolicy: OnFailure
|
restartPolicy: OnFailure
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -129,7 +129,7 @@ The endpoint returns:
|
||||||
grep CRON /var/log/syslog
|
grep CRON /var/log/syslog
|
||||||
|
|
||||||
# View custom log (if using cron with redirect)
|
# View custom log (if using cron with redirect)
|
||||||
tail -f /var/log/libra-reconciliation.log
|
tail -f /var/log/castle-reconciliation.log
|
||||||
```
|
```
|
||||||
|
|
||||||
### Success Criteria
|
### Success Criteria
|
||||||
|
|
@ -142,7 +142,7 @@ tail -f /var/log/libra-reconciliation.log
|
||||||
|
|
||||||
If `failed > 0`:
|
If `failed > 0`:
|
||||||
1. Check the `failed_assertions` array for details
|
1. Check the `failed_assertions` array for details
|
||||||
2. Investigate discrepancies in the Libra UI
|
2. Investigate discrepancies in the Castle UI
|
||||||
3. Review recent transactions
|
3. Review recent transactions
|
||||||
4. Check for data entry errors
|
4. Check for data entry errors
|
||||||
5. Verify exchange rate conversions (for fiat)
|
5. Verify exchange rate conversions (for fiat)
|
||||||
|
|
@ -172,7 +172,7 @@ Planned features:
|
||||||
|
|
||||||
3. **Check network connectivity**:
|
3. **Check network connectivity**:
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:5000/libra/api/v1/reconciliation/summary -H "X-Api-Key: YOUR_KEY"
|
curl http://localhost:5000/castle/api/v1/reconciliation/summary -H "X-Api-Key: YOUR_KEY"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Permission Denied
|
### Permission Denied
|
||||||
|
|
@ -202,31 +202,31 @@ Planned features:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# setup-libra-reconciliation.sh
|
# setup-castle-reconciliation.sh
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
LNBITS_URL="http://localhost:5000"
|
LNBITS_URL="http://localhost:5000"
|
||||||
ADMIN_KEY="your_admin_key_here"
|
ADMIN_KEY="your_admin_key_here"
|
||||||
LOG_FILE="/var/log/libra-reconciliation.log"
|
LOG_FILE="/var/log/castle-reconciliation.log"
|
||||||
|
|
||||||
# Create log file
|
# Create log file
|
||||||
touch "$LOG_FILE"
|
touch "$LOG_FILE"
|
||||||
chmod 644 "$LOG_FILE"
|
chmod 644 "$LOG_FILE"
|
||||||
|
|
||||||
# Add cron job
|
# Add cron job
|
||||||
(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 -
|
(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 -
|
||||||
|
|
||||||
echo "Daily reconciliation scheduled for 2 AM"
|
echo "Daily reconciliation scheduled for 2 AM"
|
||||||
echo "Logs will be written to: $LOG_FILE"
|
echo "Logs will be written to: $LOG_FILE"
|
||||||
|
|
||||||
# Test the endpoint
|
# Test the endpoint
|
||||||
echo "Running test reconciliation..."
|
echo "Running test reconciliation..."
|
||||||
curl -X POST "$LNBITS_URL/libra/api/v1/tasks/daily-reconciliation" \
|
curl -X POST "$LNBITS_URL/castle/api/v1/tasks/daily-reconciliation" \
|
||||||
-H "X-Api-Key: $ADMIN_KEY"
|
-H "X-Api-Key: $ADMIN_KEY"
|
||||||
```
|
```
|
||||||
|
|
||||||
Make executable and run:
|
Make executable and run:
|
||||||
```bash
|
```bash
|
||||||
chmod +x setup-libra-reconciliation.sh
|
chmod +x setup-castle-reconciliation.sh
|
||||||
./setup-libra-reconciliation.sh
|
./setup-castle-reconciliation.sh
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Libra Extension - Comprehensive Documentation
|
# Castle Accounting Extension - Comprehensive Documentation
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -19,23 +19,23 @@ The system implements traditional **double-entry bookkeeping** principles:
|
||||||
|
|
||||||
| Account Type | Normal Balance | Increases With | Decreases With | Purpose |
|
| Account Type | Normal Balance | Increases With | Decreases With | Purpose |
|
||||||
|--------------|----------------|----------------|----------------|---------|
|
|--------------|----------------|----------------|----------------|---------|
|
||||||
| Asset | Debit | Debit | Credit | What Libra owns or is owed |
|
| Asset | Debit | Debit | Credit | What Castle owns or is owed |
|
||||||
| Liability | Credit | Credit | Debit | What Libra owes to others |
|
| Liability | Credit | Credit | Debit | What Castle owes to others |
|
||||||
| Equity | Credit | Credit | Debit | Member contributions, retained earnings |
|
| Equity | Credit | Credit | Debit | Member contributions, retained earnings |
|
||||||
| Revenue | Credit | Credit | Debit | Income earned by Libra |
|
| Revenue | Credit | Credit | Debit | Income earned by Castle |
|
||||||
| Expense | Debit | Debit | Credit | Costs incurred by Libra |
|
| Expense | Debit | Debit | Credit | Costs incurred by Castle |
|
||||||
|
|
||||||
### User-Specific Accounts
|
### User-Specific Accounts
|
||||||
|
|
||||||
The system creates **per-user accounts** for tracking individual balances:
|
The system creates **per-user accounts** for tracking individual balances:
|
||||||
|
|
||||||
- `Accounts Receivable - {user_id[:8]}` (Asset) - User owes Libra
|
- `Accounts Receivable - {user_id[:8]}` (Asset) - User owes Castle
|
||||||
- `Accounts Payable - {user_id[:8]}` (Liability) - Libra owes User
|
- `Accounts Payable - {user_id[:8]}` (Liability) - Castle owes User
|
||||||
- `Member Equity - {user_id[:8]}` (Equity) - User's equity contributions
|
- `Member Equity - {user_id[:8]}` (Equity) - User's equity contributions
|
||||||
|
|
||||||
**Balance Interpretation:**
|
**Balance Interpretation:**
|
||||||
- `balance > 0` and account is Liability → Libra owes user (user is creditor)
|
- `balance > 0` and account is Liability → Castle owes user (user is creditor)
|
||||||
- `balance < 0` (or positive Asset balance) → User owes Libra (user is debtor)
|
- `balance < 0` (or positive Asset balance) → User owes Castle (user is debtor)
|
||||||
|
|
||||||
### Database Schema
|
### Database Schema
|
||||||
|
|
||||||
|
|
@ -81,7 +81,7 @@ CREATE TABLE entry_lines (
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE extension_settings (
|
CREATE TABLE extension_settings (
|
||||||
id TEXT NOT NULL PRIMARY KEY, -- Always "admin"
|
id TEXT NOT NULL PRIMARY KEY, -- Always "admin"
|
||||||
libra_wallet_id TEXT, -- LNbits wallet ID for Libra operations
|
castle_wallet_id TEXT, -- LNbits wallet ID for Castle operations
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
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)
|
### 1. User Adds Expense (Liability Model)
|
||||||
|
|
||||||
**Use Case:** User pays for groceries with cash, Libra reimburses them
|
**Use Case:** User pays for groceries with cash, Castle reimburses them
|
||||||
|
|
||||||
**User Action:** Add expense via UI
|
**User Action:** Add expense via UI
|
||||||
```javascript
|
```javascript
|
||||||
POST /libra/api/v1/entries/expense
|
POST /castle/api/v1/entries/expense
|
||||||
{
|
{
|
||||||
"description": "Biocoop groceries",
|
"description": "Biocoop groceries",
|
||||||
"amount": 36.93,
|
"amount": 36.93,
|
||||||
|
|
@ -162,15 +162,15 @@ Metadata on both lines:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Effect:** Libra owes user €36.93 (39,669 sats)
|
**Effect:** Castle owes user €36.93 (39,669 sats)
|
||||||
|
|
||||||
### 2. Libra Adds Receivable
|
### 2. Castle Adds Receivable
|
||||||
|
|
||||||
**Use Case:** User stays in a room, owes Libra for accommodation
|
**Use Case:** User stays in a room, owes Castle for accommodation
|
||||||
|
|
||||||
**Libra Admin Action:** Add receivable via UI
|
**Castle Admin Action:** Add receivable via UI
|
||||||
```javascript
|
```javascript
|
||||||
POST /libra/api/v1/entries/receivable
|
POST /castle/api/v1/entries/receivable
|
||||||
{
|
{
|
||||||
"description": "room 5 days",
|
"description": "room 5 days",
|
||||||
"amount": 250.0,
|
"amount": 250.0,
|
||||||
|
|
@ -198,7 +198,7 @@ Metadata:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Effect:** User owes Libra €250.00 (268,548 sats)
|
**Effect:** User owes Castle €250.00 (268,548 sats)
|
||||||
|
|
||||||
### 3. User Pays with Lightning
|
### 3. User Pays with Lightning
|
||||||
|
|
||||||
|
|
@ -206,7 +206,7 @@ Metadata:
|
||||||
|
|
||||||
**Step A: Generate Invoice**
|
**Step A: Generate Invoice**
|
||||||
```javascript
|
```javascript
|
||||||
POST /libra/api/v1/generate-payment-invoice
|
POST /castle/api/v1/generate-payment-invoice
|
||||||
{
|
{
|
||||||
"amount": 268548
|
"amount": 268548
|
||||||
}
|
}
|
||||||
|
|
@ -218,19 +218,19 @@ Returns:
|
||||||
"payment_hash": "...",
|
"payment_hash": "...",
|
||||||
"payment_request": "lnbc...",
|
"payment_request": "lnbc...",
|
||||||
"amount": 268548,
|
"amount": 268548,
|
||||||
"memo": "Payment from user af983632 to Libra",
|
"memo": "Payment from user af983632 to Castle",
|
||||||
"check_wallet_key": "libra_wallet_inkey"
|
"check_wallet_key": "castle_wallet_inkey"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** Invoice is generated on **Libra's wallet**, not user's wallet. User polls using `check_wallet_key`.
|
**Note:** Invoice is generated on **Castle's wallet**, not user's wallet. User polls using `check_wallet_key`.
|
||||||
|
|
||||||
**Step B: User Pays Invoice**
|
**Step B: User Pays Invoice**
|
||||||
(External Lightning wallet or LNbits wallet)
|
(External Lightning wallet or LNbits wallet)
|
||||||
|
|
||||||
**Step C: Record Payment**
|
**Step C: Record Payment**
|
||||||
```javascript
|
```javascript
|
||||||
POST /libra/api/v1/record-payment
|
POST /castle/api/v1/record-payment
|
||||||
{
|
{
|
||||||
"payment_hash": "..."
|
"payment_hash": "..."
|
||||||
}
|
}
|
||||||
|
|
@ -250,11 +250,11 @@ DR Lightning Balance 268,548 sats
|
||||||
|
|
||||||
### 4. Manual Payment Request Flow
|
### 4. Manual Payment Request Flow
|
||||||
|
|
||||||
**Use Case:** User wants Libra to pay them in cash instead of Lightning
|
**Use Case:** User wants Castle to pay them in cash instead of Lightning
|
||||||
|
|
||||||
**Step A: User Requests Payment**
|
**Step A: User Requests Payment**
|
||||||
```javascript
|
```javascript
|
||||||
POST /libra/api/v1/manual-payment-requests
|
POST /castle/api/v1/manual-payment-requests
|
||||||
{
|
{
|
||||||
"amount": 39669,
|
"amount": 39669,
|
||||||
"description": "Please pay me in cash for groceries"
|
"description": "Please pay me in cash for groceries"
|
||||||
|
|
@ -263,16 +263,16 @@ POST /libra/api/v1/manual-payment-requests
|
||||||
|
|
||||||
Creates `manual_payment_request` with status='pending'
|
Creates `manual_payment_request` with status='pending'
|
||||||
|
|
||||||
**Step B: Libra Admin Reviews**
|
**Step B: Castle Admin Reviews**
|
||||||
|
|
||||||
Admin sees pending request in UI:
|
Admin sees pending request in UI:
|
||||||
- User: af983632
|
- User: af983632
|
||||||
- Amount: 39,669 sats (€36.93)
|
- Amount: 39,669 sats (€36.93)
|
||||||
- Description: "Please pay me in cash for groceries"
|
- Description: "Please pay me in cash for groceries"
|
||||||
|
|
||||||
**Step C: Libra Admin Approves**
|
**Step C: Castle Admin Approves**
|
||||||
```javascript
|
```javascript
|
||||||
POST /libra/api/v1/manual-payment-requests/{id}/approve
|
POST /castle/api/v1/manual-payment-requests/{id}/approve
|
||||||
```
|
```
|
||||||
|
|
||||||
**Journal Entry Created:**
|
**Journal Entry Created:**
|
||||||
|
|
@ -285,11 +285,11 @@ DR Accounts Payable - af983632 39,669 sats
|
||||||
CR Lightning Balance 39,669 sats
|
CR Lightning Balance 39,669 sats
|
||||||
```
|
```
|
||||||
|
|
||||||
**Effect:** Libra's liability to user reduced by 39,669 sats
|
**Effect:** Castle's liability to user reduced by 39,669 sats
|
||||||
|
|
||||||
**Alternative: Libra Admin Rejects**
|
**Alternative: Castle Admin Rejects**
|
||||||
```javascript
|
```javascript
|
||||||
POST /libra/api/v1/manual-payment-requests/{id}/reject
|
POST /castle/api/v1/manual-payment-requests/{id}/reject
|
||||||
```
|
```
|
||||||
No journal entry created, request marked as 'rejected'.
|
No journal entry created, request marked as 'rejected'.
|
||||||
|
|
||||||
|
|
@ -308,20 +308,20 @@ for account in user_accounts:
|
||||||
|
|
||||||
# Calculate satoshi balance
|
# Calculate satoshi balance
|
||||||
if account.account_type == AccountType.LIABILITY:
|
if account.account_type == AccountType.LIABILITY:
|
||||||
total_balance += account_balance # Positive = Libra owes user
|
total_balance += account_balance # Positive = Castle owes user
|
||||||
elif account.account_type == AccountType.ASSET:
|
elif account.account_type == AccountType.ASSET:
|
||||||
total_balance -= account_balance # Positive asset = User owes Libra, so negative balance
|
total_balance -= account_balance # Positive asset = User owes Castle, so negative balance
|
||||||
|
|
||||||
# Calculate fiat balance from metadata
|
# Calculate fiat balance from metadata
|
||||||
# Beancount-style: positive amount = debit, negative amount = credit
|
# Beancount-style: positive amount = debit, negative amount = credit
|
||||||
for line in account_entry_lines:
|
for line in account_entry_lines:
|
||||||
if line.metadata.fiat_currency and line.metadata.fiat_amount:
|
if line.metadata.fiat_currency and line.metadata.fiat_amount:
|
||||||
if account.account_type == AccountType.LIABILITY:
|
if account.account_type == AccountType.LIABILITY:
|
||||||
# For liabilities, negative amounts (credits) increase what libra owes
|
# For liabilities, negative amounts (credits) increase what castle owes
|
||||||
if line.amount < 0:
|
if line.amount < 0:
|
||||||
fiat_balances[currency] += fiat_amount # Libra owes more
|
fiat_balances[currency] += fiat_amount # Castle owes more
|
||||||
else:
|
else:
|
||||||
fiat_balances[currency] -= fiat_amount # Libra owes less
|
fiat_balances[currency] -= fiat_amount # Castle owes less
|
||||||
elif account.account_type == AccountType.ASSET:
|
elif account.account_type == AccountType.ASSET:
|
||||||
# For assets, positive amounts (debits) increase what user owes
|
# For assets, positive amounts (debits) increase what user owes
|
||||||
if line.amount > 0:
|
if line.amount > 0:
|
||||||
|
|
@ -331,19 +331,19 @@ for account in user_accounts:
|
||||||
```
|
```
|
||||||
|
|
||||||
**Result:**
|
**Result:**
|
||||||
- `balance > 0`: Libra owes user (LIABILITY side dominates)
|
- `balance > 0`: Castle owes user (LIABILITY side dominates)
|
||||||
- `balance < 0`: User owes Libra (ASSET side dominates)
|
- `balance < 0`: User owes Castle (ASSET side dominates)
|
||||||
- `fiat_balances`: Net fiat position per currency
|
- `fiat_balances`: Net fiat position per currency
|
||||||
|
|
||||||
### Libra Balance Calculation
|
### Castle Balance Calculation
|
||||||
|
|
||||||
From `views_api.py:api_get_my_balance()` (super user):
|
From `views_api.py:api_get_my_balance()` (super user):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
all_balances = get_all_user_balances()
|
all_balances = get_all_user_balances()
|
||||||
|
|
||||||
total_liabilities = sum(b.balance for b in all_balances if b.balance > 0) # What Libra owes
|
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 Libra
|
total_receivables = sum(abs(b.balance) for b in all_balances if b.balance < 0) # What is owed to Castle
|
||||||
net_balance = total_liabilities - total_receivables
|
net_balance = total_liabilities - total_receivables
|
||||||
|
|
||||||
# Aggregate all fiat balances
|
# Aggregate all fiat balances
|
||||||
|
|
@ -354,34 +354,34 @@ for user_balance in all_balances:
|
||||||
```
|
```
|
||||||
|
|
||||||
**Result:**
|
**Result:**
|
||||||
- `net_balance > 0`: Libra owes users (net liability)
|
- `net_balance > 0`: Castle owes users (net liability)
|
||||||
- `net_balance < 0`: Users owe Libra (net receivable)
|
- `net_balance < 0`: Users owe Castle (net receivable)
|
||||||
|
|
||||||
## UI/UX Design
|
## UI/UX Design
|
||||||
|
|
||||||
### Perspective-Based Display
|
### Perspective-Based Display
|
||||||
|
|
||||||
The UI adapts based on whether the viewer is a regular user or Libra admin (super user):
|
The UI adapts based on whether the viewer is a regular user or Castle admin (super user):
|
||||||
|
|
||||||
#### User View
|
#### User View
|
||||||
|
|
||||||
**Balance Display:**
|
**Balance Display:**
|
||||||
- Green text: Libra owes them (positive balance, incoming money)
|
- Green text: Castle owes them (positive balance, incoming money)
|
||||||
- Red text: They owe Libra (negative balance, outgoing money)
|
- Red text: They owe Castle (negative balance, outgoing money)
|
||||||
|
|
||||||
**Transaction Badges:**
|
**Transaction Badges:**
|
||||||
- Green "Receivable": Libra owes them (Accounts Payable entry)
|
- Green "Receivable": Castle owes them (Accounts Payable entry)
|
||||||
- Red "Payable": They owe Libra (Accounts Receivable entry)
|
- Red "Payable": They owe Castle (Accounts Receivable entry)
|
||||||
|
|
||||||
#### Libra Admin View (Super User)
|
#### Castle Admin View (Super User)
|
||||||
|
|
||||||
**Balance Display:**
|
**Balance Display:**
|
||||||
- Red text: Libra owes users (positive balance, outgoing money)
|
- Red text: Castle owes users (positive balance, outgoing money)
|
||||||
- Green text: Users owe Libra (negative balance, incoming money)
|
- Green text: Users owe Castle (negative balance, incoming money)
|
||||||
|
|
||||||
**Transaction Badges:**
|
**Transaction Badges:**
|
||||||
- Green "Receivable": User owes Libra (Accounts Receivable entry)
|
- Green "Receivable": User owes Castle (Accounts Receivable entry)
|
||||||
- Red "Payable": Libra owes user (Accounts Payable entry)
|
- Red "Payable": Castle owes user (Accounts Payable entry)
|
||||||
|
|
||||||
**Outstanding Balances Table:**
|
**Outstanding Balances Table:**
|
||||||
Shows all users with non-zero balances:
|
Shows all users with non-zero balances:
|
||||||
|
|
@ -411,10 +411,10 @@ Created by `m001_initial` migration:
|
||||||
- `cash` - Cash on hand
|
- `cash` - Cash on hand
|
||||||
- `bank` - Bank Account
|
- `bank` - Bank Account
|
||||||
- `lightning` - Lightning Balance
|
- `lightning` - Lightning Balance
|
||||||
- `accounts_receivable` - Money owed to the organization
|
- `accounts_receivable` - Money owed to the Castle
|
||||||
|
|
||||||
### Liabilities
|
### Liabilities
|
||||||
- `accounts_payable` - Money owed by the organization
|
- `accounts_payable` - Money owed by the Castle
|
||||||
|
|
||||||
### Equity
|
### Equity
|
||||||
- `member_equity` - Member contributions
|
- `member_equity` - Member contributions
|
||||||
|
|
@ -449,11 +449,11 @@ Created by `m001_initial` migration:
|
||||||
- `POST /api/v1/entries/revenue` - Create direct revenue entry (admin only)
|
- `POST /api/v1/entries/revenue` - Create direct revenue entry (admin only)
|
||||||
|
|
||||||
### Balance & Payments
|
### Balance & Payments
|
||||||
- `GET /api/v1/balance` - Get current user's balance (or Libra total if super user)
|
- `GET /api/v1/balance` - Get current user's balance (or Castle total if super user)
|
||||||
- `GET /api/v1/balance/{user_id}` - Get specific user's balance
|
- `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)
|
- `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 Libra
|
- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Castle
|
||||||
- `POST /api/v1/record-payment` - Record Lightning payment to Libra
|
- `POST /api/v1/record-payment` - Record Lightning payment to Castle
|
||||||
|
|
||||||
### Manual Payments
|
### Manual Payments
|
||||||
- `POST /api/v1/manual-payment-requests` - User creates manual payment request
|
- `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
|
- `POST /api/v1/manual-payment-requests/{id}/reject` - Admin rejects request
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
- `GET /api/v1/settings` - Get Libra settings (super user only)
|
- `GET /api/v1/settings` - Get Castle settings (super user only)
|
||||||
- `PUT /api/v1/settings` - Update Libra settings (super user only)
|
- `PUT /api/v1/settings` - Update Castle settings (super user only)
|
||||||
- `GET /api/v1/user/wallet` - Get user's wallet settings
|
- `GET /api/v1/user/wallet` - Get user's wallet settings
|
||||||
- `PUT /api/v1/user/wallet` - Update 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)
|
- `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:**
|
**Add Endpoint:**
|
||||||
```python
|
```python
|
||||||
@libra_api_router.get("/api/v1/export/beancount")
|
@castle_api_router.get("/api/v1/export/beancount")
|
||||||
async def export_beancount(
|
async def export_beancount(
|
||||||
start_date: Optional[str] = None,
|
start_date: Optional[str] = None,
|
||||||
end_date: Optional[str] = None,
|
end_date: Optional[str] = None,
|
||||||
|
|
@ -812,7 +812,7 @@ async def export_beancount(
|
||||||
|
|
||||||
**UI Addition:**
|
**UI Addition:**
|
||||||
|
|
||||||
Add export button to Libra admin UI:
|
Add export button to Castle admin UI:
|
||||||
```html
|
```html
|
||||||
<q-btn color="primary" @click="exportBeancount">
|
<q-btn color="primary" @click="exportBeancount">
|
||||||
Export to Beancount
|
Export to Beancount
|
||||||
|
|
@ -825,7 +825,7 @@ async exportBeancount() {
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/export/beancount',
|
'/castle/api/v1/export/beancount',
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -834,7 +834,7 @@ async exportBeancount() {
|
||||||
const url = window.URL.createObjectURL(blob)
|
const url = window.URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = `libra-accounting-${new Date().toISOString().split('T')[0]}.beancount`
|
link.download = `castle-accounting-${new Date().toISOString().split('T')[0]}.beancount`
|
||||||
link.click()
|
link.click()
|
||||||
window.URL.revokeObjectURL(url)
|
window.URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
|
@ -854,12 +854,12 @@ After export, users can verify with Beancount:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check file is valid
|
# Check file is valid
|
||||||
bean-check libra-accounting-2025-10-22.beancount
|
bean-check castle-accounting-2025-10-22.beancount
|
||||||
|
|
||||||
# Generate reports
|
# Generate reports
|
||||||
bean-report libra-accounting-2025-10-22.beancount balances
|
bean-report castle-accounting-2025-10-22.beancount balances
|
||||||
bean-report libra-accounting-2025-10-22.beancount income
|
bean-report castle-accounting-2025-10-22.beancount income
|
||||||
bean-web libra-accounting-2025-10-22.beancount
|
bean-web castle-accounting-2025-10-22.beancount
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing Strategy
|
## Testing Strategy
|
||||||
|
|
@ -891,7 +891,7 @@ bean-web libra-accounting-2025-10-22.beancount
|
||||||
|
|
||||||
1. **End-to-End User Flow**
|
1. **End-to-End User Flow**
|
||||||
- User adds expense
|
- User adds expense
|
||||||
- Libra adds receivable
|
- Castle adds receivable
|
||||||
- User pays via Lightning
|
- User pays via Lightning
|
||||||
- Verify balances at each step
|
- Verify balances at each step
|
||||||
|
|
||||||
|
|
@ -904,7 +904,7 @@ bean-web libra-accounting-2025-10-22.beancount
|
||||||
3. **Multi-User Scenarios**
|
3. **Multi-User Scenarios**
|
||||||
- Multiple users with positive balances
|
- Multiple users with positive balances
|
||||||
- Multiple users with negative balances
|
- Multiple users with negative balances
|
||||||
- Verify Libra net balance calculation
|
- Verify Castle net balance calculation
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
|
|
@ -916,12 +916,12 @@ bean-web libra-accounting-2025-10-22.beancount
|
||||||
|
|
||||||
2. **User Isolation**
|
2. **User Isolation**
|
||||||
- Users can only see their own balances and transactions
|
- Users can only see their own balances and transactions
|
||||||
- Users cannot create receivables (only Libra admin can)
|
- Users cannot create receivables (only Castle admin can)
|
||||||
- Users cannot approve their own manual payment requests
|
- Users cannot approve their own manual payment requests
|
||||||
|
|
||||||
3. **Wallet Key Requirements**
|
3. **Wallet Key Requirements**
|
||||||
- `require_invoice_key`: Read access to user's data
|
- `require_invoice_key`: Read access to user's data
|
||||||
- `require_admin_key`: Write access, Libra admin operations
|
- `require_admin_key`: Write access, Castle admin operations
|
||||||
|
|
||||||
### Potential Vulnerabilities
|
### Potential Vulnerabilities
|
||||||
|
|
||||||
|
|
@ -959,7 +959,7 @@ bean-web libra-accounting-2025-10-22.beancount
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
@libra_api_router.post("/api/v1/entries/expense")
|
@castle_api_router.post("/api/v1/entries/expense")
|
||||||
async def api_create_expense_entry(...):
|
async def api_create_expense_entry(...):
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
@ -1020,7 +1020,7 @@ bean-web libra-accounting-2025-10-22.beancount
|
||||||
|
|
||||||
2. **Add Pagination**
|
2. **Add Pagination**
|
||||||
```python
|
```python
|
||||||
@libra_api_router.get("/api/v1/entries/user")
|
@castle_api_router.get("/api/v1/entries/user")
|
||||||
async def api_get_user_entries(
|
async def api_get_user_entries(
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
|
@ -1092,7 +1092,7 @@ bean-web libra-accounting-2025-10-22.beancount
|
||||||
|
|
||||||
## Migration Path for Existing Data
|
## Migration Path for Existing Data
|
||||||
|
|
||||||
If Libra is already in production with the old code:
|
If Castle is already in production with the old code:
|
||||||
|
|
||||||
### Migration Script: `m005_fix_user_accounts.py`
|
### Migration Script: `m005_fix_user_accounts.py`
|
||||||
|
|
||||||
|
|
@ -1185,7 +1185,7 @@ async def m005_fix_user_accounts(db):
|
||||||
|
|
||||||
## Conclusion
|
## Conclusion
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### Strengths
|
### Strengths
|
||||||
✅ Correct double-entry bookkeeping implementation
|
✅ Correct double-entry bookkeeping implementation
|
||||||
|
|
@ -1193,7 +1193,7 @@ The Libra extension provides a solid foundation for double-entry bookkeeping in
|
||||||
✅ Metadata preservation for fiat amounts
|
✅ Metadata preservation for fiat amounts
|
||||||
✅ Lightning payment integration
|
✅ Lightning payment integration
|
||||||
✅ Manual payment workflow
|
✅ Manual payment workflow
|
||||||
✅ Perspective-based UI (user vs Libra view)
|
✅ Perspective-based UI (user vs Castle view)
|
||||||
|
|
||||||
### Immediate Action Items
|
### Immediate Action Items
|
||||||
1. ✅ Fix user account creation bug (COMPLETED)
|
1. ✅ Fix user account creation bug (COMPLETED)
|
||||||
|
|
@ -1210,4 +1210,4 @@ The Libra extension provides a solid foundation for double-entry bookkeeping in
|
||||||
5. Equity management features
|
5. Equity management features
|
||||||
6. External system integrations (accounting software, tax tools)
|
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 collective 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 cooperative accounting solution.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
|
|
@ -61,7 +61,7 @@ AND je.flag = '*' -- Only cleared entries
|
||||||
|
|
||||||
### Get Pending Entries (Admin Only)
|
### Get Pending Entries (Admin Only)
|
||||||
```
|
```
|
||||||
GET /libra/api/v1/entries/pending
|
GET /castle/api/v1/entries/pending
|
||||||
Authorization: Admin Key
|
Authorization: Admin Key
|
||||||
|
|
||||||
Returns: list[JournalEntry]
|
Returns: list[JournalEntry]
|
||||||
|
|
@ -69,7 +69,7 @@ Returns: list[JournalEntry]
|
||||||
|
|
||||||
### Approve Expense (Admin Only)
|
### Approve Expense (Admin Only)
|
||||||
```
|
```
|
||||||
POST /libra/api/v1/entries/{entry_id}/approve
|
POST /castle/api/v1/entries/{entry_id}/approve
|
||||||
Authorization: Admin Key
|
Authorization: Admin Key
|
||||||
|
|
||||||
Returns: JournalEntry (with flag='*')
|
Returns: JournalEntry (with flag='*')
|
||||||
|
|
@ -77,7 +77,7 @@ Returns: JournalEntry (with flag='*')
|
||||||
|
|
||||||
### Reject Expense (Admin Only)
|
### Reject Expense (Admin Only)
|
||||||
```
|
```
|
||||||
POST /libra/api/v1/entries/{entry_id}/reject
|
POST /castle/api/v1/entries/{entry_id}/reject
|
||||||
Authorization: Admin Key
|
Authorization: Admin Key
|
||||||
|
|
||||||
Returns: JournalEntry (with flag='x')
|
Returns: JournalEntry (with flag='x')
|
||||||
|
|
@ -133,7 +133,7 @@ Returns: JournalEntry (with flag='x')
|
||||||
|
|
||||||
1. **Submit test expense as regular user**
|
1. **Submit test expense as regular user**
|
||||||
```
|
```
|
||||||
POST /libra/api/v1/entries/expense
|
POST /castle/api/v1/entries/expense
|
||||||
{
|
{
|
||||||
"description": "Test groceries",
|
"description": "Test groceries",
|
||||||
"amount": 50.00,
|
"amount": 50.00,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Libra Permissions System - Overview & Administration Guide
|
# Castle Permissions System - Overview & Administration Guide
|
||||||
|
|
||||||
**Date**: November 10, 2025
|
**Date**: November 10, 2025
|
||||||
**Status**: 📚 **Documentation** + 🔧 **Improvement Recommendations**
|
**Status**: 📚 **Documentation** + 🔧 **Improvement Recommendations**
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
**Key Features:**
|
**Key Features:**
|
||||||
- ✅ **Three permission levels**: READ, SUBMIT_EXPENSE, MANAGE
|
- ✅ **Three permission levels**: READ, SUBMIT_EXPENSE, MANAGE
|
||||||
|
|
@ -680,7 +680,7 @@ CREATE TABLE account_permissions (
|
||||||
expires_at TIMESTAMP,
|
expires_at TIMESTAMP,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
|
|
||||||
FOREIGN KEY (account_id) REFERENCES libra_accounts (id)
|
FOREIGN KEY (account_id) REFERENCES castle_accounts (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_account_permissions_user_id ON account_permissions (user_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
|
## Summary
|
||||||
|
|
||||||
The Libra permissions system is **well-designed** with strong features:
|
The Castle permissions system is **well-designed** with strong features:
|
||||||
- Hierarchical inheritance reduces admin burden
|
- Hierarchical inheritance reduces admin burden
|
||||||
- Caching provides good performance
|
- Caching provides good performance
|
||||||
- Expiration and audit trail support compliance
|
- Expiration and audit trail support compliance
|
||||||
|
|
|
||||||
|
|
@ -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
|
- `POST /api/v1/assertions/{id}/check` - Re-check assertion
|
||||||
- `DELETE /api/v1/assertions/{id}` - Delete assertion
|
- `DELETE /api/v1/assertions/{id}` - Delete assertion
|
||||||
|
|
||||||
- **UI** (`templates/libra/index.html:254-378`):
|
- **UI** (`templates/castle/index.html:254-378`):
|
||||||
- Balance Assertions card (super user only)
|
- Balance Assertions card (super user only)
|
||||||
- Failed assertions prominently displayed with red banner
|
- Failed assertions prominently displayed with red banner
|
||||||
- Passed assertions in collapsible panel
|
- 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
|
**Purpose**: Visual dashboard for reconciliation status and quick access to reconciliation tools
|
||||||
|
|
||||||
**Implementation** (`templates/libra/index.html:380-499`):
|
**Implementation** (`templates/castle/index.html:380-499`):
|
||||||
- **Summary Cards**:
|
- **Summary Cards**:
|
||||||
- Balance Assertions stats (total, passed, failed, pending)
|
- Balance Assertions stats (total, passed, failed, pending)
|
||||||
- Journal Entries stats (total, cleared, pending, flagged)
|
- 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
|
2. `migrations.py` - Added `m007_balance_assertions` migration
|
||||||
3. `crud.py` - Added balance assertion CRUD operations
|
3. `crud.py` - Added balance assertion CRUD operations
|
||||||
4. `views_api.py` - Added assertion, reconciliation, and task endpoints
|
4. `views_api.py` - Added assertion, reconciliation, and task endpoints
|
||||||
5. `templates/libra/index.html` - Added assertions and reconciliation UI
|
5. `templates/castle/index.html` - Added assertions and reconciliation UI
|
||||||
6. `static/js/index.js` - Added assertion and reconciliation functionality
|
6. `static/js/index.js` - Added assertion and reconciliation functionality
|
||||||
7. `BEANCOUNT_PATTERNS.md` - Updated roadmap to mark Phase 2 complete
|
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
|
### Create a Balance Assertion
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:5000/libra/api/v1/assertions \
|
curl -X POST http://localhost:5000/castle/api/v1/assertions \
|
||||||
-H "X-Api-Key: ADMIN_KEY" \
|
-H "X-Api-Key: ADMIN_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
|
|
@ -198,20 +198,20 @@ curl -X POST http://localhost:5000/libra/api/v1/assertions \
|
||||||
|
|
||||||
### Get Reconciliation Summary
|
### Get Reconciliation Summary
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:5000/libra/api/v1/reconciliation/summary \
|
curl http://localhost:5000/castle/api/v1/reconciliation/summary \
|
||||||
-H "X-Api-Key: ADMIN_KEY"
|
-H "X-Api-Key: ADMIN_KEY"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run Full Reconciliation
|
### Run Full Reconciliation
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:5000/libra/api/v1/reconciliation/check-all \
|
curl -X POST http://localhost:5000/castle/api/v1/reconciliation/check-all \
|
||||||
-H "X-Api-Key: ADMIN_KEY"
|
-H "X-Api-Key: ADMIN_KEY"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Schedule Daily Reconciliation (Cron)
|
### Schedule Daily Reconciliation (Cron)
|
||||||
```bash
|
```bash
|
||||||
# Add to crontab
|
# Add to crontab
|
||||||
0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ADMIN_KEY"
|
0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ADMIN_KEY"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing Checklist
|
## Testing Checklist
|
||||||
|
|
@ -238,7 +238,7 @@ curl -X POST http://localhost:5000/libra/api/v1/reconciliation/check-all \
|
||||||
|
|
||||||
**Phase 3: Core Logic Refactoring (Medium Priority)**
|
**Phase 3: Core Logic Refactoring (Medium Priority)**
|
||||||
- Create `core/` module with pure accounting logic
|
- Create `core/` module with pure accounting logic
|
||||||
- Implement `LibraInventory` for position tracking
|
- Implement `CastleInventory` for position tracking
|
||||||
- Move balance calculation to `core/balance.py`
|
- Move balance calculation to `core/balance.py`
|
||||||
- Add comprehensive validation in `core/validation.py`
|
- Add comprehensive validation in `core/validation.py`
|
||||||
|
|
||||||
|
|
@ -256,7 +256,7 @@ curl -X POST http://localhost:5000/libra/api/v1/reconciliation/check-all \
|
||||||
|
|
||||||
## Conclusion
|
## Conclusion
|
||||||
|
|
||||||
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:
|
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:
|
||||||
|
|
||||||
- **Trust their data** with automated verification
|
- **Trust their data** with automated verification
|
||||||
- **Catch errors early** through regular reconciliation
|
- **Catch errors early** through regular reconciliation
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,13 @@ Phase 3 of the Beancount-inspired refactor focused on **separating business logi
|
||||||
- Easier to audit and verify
|
- Easier to audit and verify
|
||||||
- Clear architecture
|
- Clear architecture
|
||||||
|
|
||||||
### 2. LibraInventory for Position Tracking ✅
|
### 2. CastleInventory for Position Tracking ✅
|
||||||
|
|
||||||
**Purpose**: Track balances across multiple currencies with cost basis information (following Beancount's Inventory pattern)
|
**Purpose**: Track balances across multiple currencies with cost basis information (following Beancount's Inventory pattern)
|
||||||
|
|
||||||
**Implementation** (`core/inventory.py`):
|
**Implementation** (`core/inventory.py`):
|
||||||
|
|
||||||
**LibraPosition** (Lines 11-84):
|
**CastlePosition** (Lines 11-84):
|
||||||
- Immutable dataclass representing a single position
|
- Immutable dataclass representing a single position
|
||||||
- Tracks currency, amount, cost basis, and metadata
|
- Tracks currency, amount, cost basis, and metadata
|
||||||
- Supports addition and negation operations
|
- Supports addition and negation operations
|
||||||
|
|
@ -35,7 +35,7 @@ Phase 3 of the Beancount-inspired refactor focused on **separating business logi
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LibraPosition:
|
class CastlePosition:
|
||||||
currency: str # "SATS", "EUR", "USD"
|
currency: str # "SATS", "EUR", "USD"
|
||||||
amount: Decimal
|
amount: Decimal
|
||||||
cost_currency: Optional[str] = None
|
cost_currency: Optional[str] = None
|
||||||
|
|
@ -44,7 +44,7 @@ class LibraPosition:
|
||||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
```
|
```
|
||||||
|
|
||||||
**LibraInventory** (Lines 87-201):
|
**CastleInventory** (Lines 87-201):
|
||||||
- Container for multiple positions
|
- Container for multiple positions
|
||||||
- Positions keyed by `(currency, cost_currency)` tuple
|
- Positions keyed by `(currency, cost_currency)` tuple
|
||||||
- Methods for querying balances:
|
- Methods for querying balances:
|
||||||
|
|
@ -83,7 +83,7 @@ class AccountType(str, Enum):
|
||||||
- Liabilities/Equity/Revenue: Credit balance (credit - debit)
|
- Liabilities/Equity/Revenue: Credit balance (credit - debit)
|
||||||
|
|
||||||
2. **`build_inventory_from_entry_lines()`** (Lines 56-117):
|
2. **`build_inventory_from_entry_lines()`** (Lines 56-117):
|
||||||
- Build LibraInventory from journal entry lines
|
- Build CastleInventory from journal entry lines
|
||||||
- Handles both sats and fiat currency tracking
|
- Handles both sats and fiat currency tracking
|
||||||
- Accounts for account type when determining sign
|
- Accounts for account type when determining sign
|
||||||
|
|
||||||
|
|
@ -123,7 +123,7 @@ class AccountType(str, Enum):
|
||||||
- Checks both sats and fiat within tolerance
|
- Checks both sats and fiat within tolerance
|
||||||
|
|
||||||
3. **`validate_receivable_entry()`** (Lines 180-199):
|
3. **`validate_receivable_entry()`** (Lines 180-199):
|
||||||
- Validates receivable (user owes libra) entries
|
- Validates receivable (user owes castle) entries
|
||||||
- Ensures positive amount
|
- Ensures positive amount
|
||||||
- Ensures revenue account type
|
- Ensures revenue account type
|
||||||
|
|
||||||
|
|
@ -216,10 +216,10 @@ views_api.py → crud.py → core/
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
lnbits/extensions/libra/
|
lnbits/extensions/castle/
|
||||||
├── core/
|
├── core/
|
||||||
│ ├── __init__.py # Module exports
|
│ ├── __init__.py # Module exports
|
||||||
│ ├── inventory.py # LibraInventory, LibraPosition
|
│ ├── inventory.py # CastleInventory, CastlePosition
|
||||||
│ ├── balance.py # BalanceCalculator
|
│ ├── balance.py # BalanceCalculator
|
||||||
│ └── validation.py # Validation functions
|
│ └── validation.py # Validation functions
|
||||||
├── crud.py # DB operations (refactored to use core/)
|
├── crud.py # DB operations (refactored to use core/)
|
||||||
|
|
@ -230,22 +230,22 @@ lnbits/extensions/libra/
|
||||||
|
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
|
|
||||||
### Using LibraInventory
|
### Using CastleInventory
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from libra.core.inventory import LibraInventory, LibraPosition
|
from castle.core.inventory import CastleInventory, CastlePosition
|
||||||
|
|
||||||
# Create inventory
|
# Create inventory
|
||||||
inv = LibraInventory()
|
inv = CastleInventory()
|
||||||
|
|
||||||
# Add positions
|
# Add positions
|
||||||
inv.add_position(LibraPosition(
|
inv.add_position(CastlePosition(
|
||||||
currency="SATS",
|
currency="SATS",
|
||||||
amount=Decimal("100000")
|
amount=Decimal("100000")
|
||||||
))
|
))
|
||||||
|
|
||||||
inv.add_position(LibraPosition(
|
inv.add_position(CastlePosition(
|
||||||
currency="SATS",
|
currency="SATS",
|
||||||
amount=Decimal("50000"),
|
amount=Decimal("50000"),
|
||||||
cost_currency="EUR",
|
cost_currency="EUR",
|
||||||
|
|
@ -264,7 +264,7 @@ data = inv.to_dict()
|
||||||
### Using BalanceCalculator
|
### Using BalanceCalculator
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from libra.core.balance import BalanceCalculator, AccountType
|
from castle.core.balance import BalanceCalculator, AccountType
|
||||||
|
|
||||||
# Calculate account balance
|
# Calculate account balance
|
||||||
balance = BalanceCalculator.calculate_account_balance(
|
balance = BalanceCalculator.calculate_account_balance(
|
||||||
|
|
@ -297,7 +297,7 @@ is_valid = BalanceCalculator.check_balance_matches(
|
||||||
### Using Validation
|
### Using Validation
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from libra.core.validation import validate_journal_entry, ValidationError
|
from castle.core.validation import validate_journal_entry, ValidationError
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
"id": "abc123",
|
"id": "abc123",
|
||||||
|
|
@ -320,8 +320,8 @@ except ValidationError as e:
|
||||||
|
|
||||||
## Testing Checklist
|
## Testing Checklist
|
||||||
|
|
||||||
- [x] LibraInventory created and tested
|
- [x] CastleInventory created and tested
|
||||||
- [x] LibraPosition addition works
|
- [x] CastlePosition addition works
|
||||||
- [x] Inventory balance calculations work
|
- [x] Inventory balance calculations work
|
||||||
- [x] BalanceCalculator account balance calculation works
|
- [x] BalanceCalculator account balance calculation works
|
||||||
- [x] BalanceCalculator inventory building works
|
- [x] BalanceCalculator inventory building works
|
||||||
|
|
@ -348,10 +348,10 @@ except ValidationError as e:
|
||||||
|
|
||||||
## Conclusion
|
## Conclusion
|
||||||
|
|
||||||
Phase 3 successfully refactors Libra's accounting logic into a clean, testable core module. By following Beancount's architecture patterns, we've created:
|
Phase 3 successfully refactors Castle'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
|
- **Pure accounting logic** separated from database concerns
|
||||||
- **LibraInventory** for position tracking across currencies
|
- **CastleInventory** for position tracking across currencies
|
||||||
- **BalanceCalculator** for consistent balance calculations
|
- **BalanceCalculator** for consistent balance calculations
|
||||||
- **Comprehensive validation** for data integrity
|
- **Comprehensive validation** for data integrity
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,21 @@
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### Quick Summary
|
### Quick Summary
|
||||||
|
|
||||||
- **Purpose**: Track Bitcoin/Lightning amounts in a EUR-denominated ledger
|
- **Purpose**: Track Bitcoin/Lightning amounts in a EUR-denominated ledger
|
||||||
- **Location**: Beancount posting metadata (not position amounts)
|
- **Location**: Beancount posting metadata (not position amounts)
|
||||||
- **Format**: String containing absolute satoshi amount (e.g., `"337096"`)
|
- **Format**: String containing absolute satoshi amount (e.g., `"337096"`)
|
||||||
- **Primary Use**: Calculate user balances in satoshis (Libra's primary currency)
|
- **Primary Use**: Calculate user balances in satoshis (Castle's primary currency)
|
||||||
- **Key Principle**: Satoshis are for reference; EUR is the actual transaction currency
|
- **Key Principle**: Satoshis are for reference; EUR is the actual transaction currency
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The Problem: Dual-Currency Tracking
|
## The Problem: Dual-Currency Tracking
|
||||||
|
|
||||||
Libra needs to track both:
|
Castle needs to track both:
|
||||||
1. **Fiat amounts** (EUR, USD) - The actual transaction currency
|
1. **Fiat amounts** (EUR, USD) - The actual transaction currency
|
||||||
2. **Bitcoin amounts** (satoshis) - The Lightning Network settlement currency
|
2. **Bitcoin amounts** (satoshis) - The Lightning Network settlement currency
|
||||||
|
|
||||||
|
|
@ -34,7 +34,7 @@ Libra needs to track both:
|
||||||
- ❌ Complicate traditional accounting reconciliation
|
- ❌ Complicate traditional accounting reconciliation
|
||||||
- ❌ Make fiat-based reporting difficult
|
- ❌ Make fiat-based reporting difficult
|
||||||
|
|
||||||
**Libra's Philosophy**: Record transactions in their **actual currency**, with Bitcoin as supplementary data.
|
**Castle'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
|
### Primary Use Case: User Balances
|
||||||
|
|
||||||
Libra's core function is tracking **who owes whom** in satoshis. The `sats-equivalent` metadata enables this.
|
Castle's core function is tracking **who owes whom** in satoshis. The `sats-equivalent` metadata enables this.
|
||||||
|
|
||||||
**Flow** (`fava_client.py:220-248`):
|
**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
|
-- Error: BQL cannot access metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
### Why Libra Accepts This Trade-off
|
### Why Castle Accepts This Trade-off
|
||||||
|
|
||||||
**Performance Analysis** (see `docs/BQL-BALANCE-QUERIES.md`):
|
**Performance Analysis** (see `docs/BQL-BALANCE-QUERIES.md`):
|
||||||
1. **Caching solves the bottleneck**: 60-80% performance improvement from caching account/permission lookups
|
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"
|
**User Action**: "I paid €36.93 cash for groceries"
|
||||||
|
|
||||||
**Libra's Internal Representation**:
|
**Castle's Internal Representation**:
|
||||||
```python
|
```python
|
||||||
# User provides or Libra calculates:
|
# User provides or Castle calculates:
|
||||||
fiat_amount = Decimal("36.93") # EUR
|
fiat_amount = Decimal("36.93") # EUR
|
||||||
fiat_currency = "EUR"
|
fiat_currency = "EUR"
|
||||||
amount_sats = 39669 # Calculated from exchange rate
|
amount_sats = 39669 # Calculated from exchange rate
|
||||||
|
|
@ -232,16 +232,16 @@ line = CreateEntryLine(
|
||||||
# - Apply sign: -36.93 is negative → sats = -39669
|
# - Apply sign: -36.93 is negative → sats = -39669
|
||||||
# - Accumulate: user_balance_sats += -39669
|
# - Accumulate: user_balance_sats += -39669
|
||||||
|
|
||||||
# Result: negative balance = Libra owes user
|
# Result: negative balance = Castle owes user
|
||||||
```
|
```
|
||||||
|
|
||||||
**User Balance Response**:
|
**User Balance Response**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"user_id": "5987ae95",
|
"user_id": "5987ae95",
|
||||||
"balance": -39669, // Libra owes user 39,669 sats
|
"balance": -39669, // Castle owes user 39,669 sats
|
||||||
"fiat_balances": {
|
"fiat_balances": {
|
||||||
"EUR": "-36.93" // Libra owes user €36.93
|
"EUR": "-36.93" // Castle 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
|
### 3. Separate Fiat and Sats Balances
|
||||||
|
|
||||||
Libra tracks TWO independent balances:
|
Castle tracks TWO independent balances:
|
||||||
- **Satoshi balance**: Sum of `sats-equivalent` metadata (primary)
|
- **Satoshi balance**: Sum of `sats-equivalent` metadata (primary)
|
||||||
- **Fiat balances**: Sum of EUR/USD position amounts (secondary)
|
- **Fiat balances**: Sum of EUR/USD position amounts (secondary)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Libra UI Improvements Plan
|
# Castle UI Improvements Plan
|
||||||
|
|
||||||
**Date**: November 10, 2025
|
**Date**: November 10, 2025
|
||||||
**Status**: 📋 **Planning Document**
|
**Status**: 📋 **Planning Document**
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Enhance the Libra permissions UI to showcase new bulk permission management and account sync features, making admin tasks faster and more intuitive.
|
Enhance the Castle 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 Libra permissions UI to showcase new bulk permission management and
|
||||||
│ │
|
│ │
|
||||||
│ ⚠️ Warning: This will revoke ALL │
|
│ ⚠️ Warning: This will revoke ALL │
|
||||||
│ permissions for this user. They will │
|
│ permissions for this user. They will │
|
||||||
│ immediately lose access to Libra. │
|
│ immediately lose access to Castle. │
|
||||||
│ │
|
│ │
|
||||||
│ Reason for Offboarding │
|
│ Reason for Offboarding │
|
||||||
│ [Employee departure - last day] │
|
│ [Employee departure - last day] │
|
||||||
|
|
@ -257,13 +257,13 @@ Enhance the Libra permissions UI to showcase new bulk permission management and
|
||||||
├───────────────────────────────────────────┤
|
├───────────────────────────────────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ Sync accounts from your Beancount ledger │
|
│ Sync accounts from your Beancount ledger │
|
||||||
│ to Libra database for permission mgmt. │
|
│ to Castle database for permission mgmt. │
|
||||||
│ │
|
│ │
|
||||||
│ Last Sync: 2 hours ago │
|
│ Last Sync: 2 hours ago │
|
||||||
│ Status: ✅ Up to date │
|
│ Status: ✅ Up to date │
|
||||||
│ │
|
│ │
|
||||||
│ Accounts in Beancount: 150 │
|
│ Accounts in Beancount: 150 │
|
||||||
│ Accounts in Libra DB: 150 │
|
│ Accounts in Castle DB: 150 │
|
||||||
│ │
|
│ │
|
||||||
│ Options: │
|
│ Options: │
|
||||||
│ ☐ Force full sync (re-check all) │
|
│ ☐ Force full sync (re-check all) │
|
||||||
|
|
@ -509,7 +509,7 @@ permissions.html
|
||||||
syncStatus: {
|
syncStatus: {
|
||||||
lastSync: null,
|
lastSync: null,
|
||||||
beancountAccounts: 0,
|
beancountAccounts: 0,
|
||||||
libraAccounts: 0,
|
castleAccounts: 0,
|
||||||
status: 'idle'
|
status: 'idle'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
866
fava_client.py
866
fava_client.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
||||||
# Libra Beancount Import Helper
|
# Castle Beancount Import Helper
|
||||||
|
|
||||||
Import Beancount ledger transactions into Libra accounting extension.
|
Import Beancount ledger transactions into Castle accounting extension.
|
||||||
|
|
||||||
## 📁 Files
|
## 📁 Files
|
||||||
|
|
||||||
|
|
@ -40,14 +40,14 @@ USER_MAPPINGS = {
|
||||||
### 3. Set API Key
|
### 3. Set API Key
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export LIBRA_ADMIN_KEY="your_lnbits_admin_invoice_key"
|
export CASTLE_ADMIN_KEY="your_lnbits_admin_invoice_key"
|
||||||
export LNBITS_URL="http://localhost:5000" # Optional
|
export LNBITS_URL="http://localhost:5000" # Optional
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📖 Usage
|
## 📖 Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /path/to/libra/helper
|
cd /path/to/castle/helper
|
||||||
|
|
||||||
# Test with dry run
|
# Test with dry run
|
||||||
python import_beancount.py ledger.beancount --dry-run
|
python import_beancount.py ledger.beancount --dry-run
|
||||||
|
|
@ -72,30 +72,30 @@ Your Beancount transactions must have an `Equity:<name>` account:
|
||||||
|
|
||||||
**Requirements:**
|
**Requirements:**
|
||||||
- Every transaction must have an `Equity:<name>` account
|
- Every transaction must have an `Equity:<name>` account
|
||||||
- Account names must match exactly what's in Libra
|
- Account names must match exactly what's in Castle
|
||||||
- The name after `Equity:` must be in `USER_MAPPINGS`
|
- The name after `Equity:` must be in `USER_MAPPINGS`
|
||||||
|
|
||||||
## 🔄 How It Works
|
## 🔄 How It Works
|
||||||
|
|
||||||
1. **Loads rates** from `btc_eur_rates.csv`
|
1. **Loads rates** from `btc_eur_rates.csv`
|
||||||
2. **Loads accounts** from Libra API automatically
|
2. **Loads accounts** from Castle API automatically
|
||||||
3. **Maps users** - Extracts user name from `Equity:Name` accounts
|
3. **Maps users** - Extracts user name from `Equity:Name` accounts
|
||||||
4. **Parses** Beancount transactions
|
4. **Parses** Beancount transactions
|
||||||
5. **Converts** EUR → sats using daily rate
|
5. **Converts** EUR → sats using daily rate
|
||||||
6. **Uploads** to Libra with metadata
|
6. **Uploads** to Castle with metadata
|
||||||
|
|
||||||
## 📊 Example Output
|
## 📊 Example Output
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ python import_beancount.py ledger.beancount
|
$ python import_beancount.py ledger.beancount
|
||||||
======================================================================
|
======================================================================
|
||||||
⚖️ Beancount to Libra Import Script
|
🏰 Beancount to Castle Import Script
|
||||||
======================================================================
|
======================================================================
|
||||||
|
|
||||||
📊 Loaded 15 daily rates from btc_eur_rates.csv
|
📊 Loaded 15 daily rates from btc_eur_rates.csv
|
||||||
Date range: 2025-07-01 to 2025-07-15
|
Date range: 2025-07-01 to 2025-07-15
|
||||||
|
|
||||||
🏦 Loaded 28 accounts from Libra
|
🏦 Loaded 28 accounts from Castle
|
||||||
|
|
||||||
👥 User ID mappings:
|
👥 User ID mappings:
|
||||||
- Pat → wallet_abc123
|
- Pat → wallet_abc123
|
||||||
|
|
@ -112,15 +112,15 @@ $ python import_beancount.py ledger.beancount
|
||||||
📊 Summary: 25 succeeded, 0 failed, 0 skipped
|
📊 Summary: 25 succeeded, 0 failed, 0 skipped
|
||||||
======================================================================
|
======================================================================
|
||||||
|
|
||||||
✅ Successfully imported 25 transactions to Libra!
|
✅ Successfully imported 25 transactions to Castle!
|
||||||
```
|
```
|
||||||
|
|
||||||
## ❓ Troubleshooting
|
## ❓ Troubleshooting
|
||||||
|
|
||||||
### "No account found in Libra"
|
### "No account found in Castle"
|
||||||
**Error:** `No account found in Libra with name 'Expenses:XYZ'`
|
**Error:** `No account found in Castle with name 'Expenses:XYZ'`
|
||||||
|
|
||||||
**Solution:** Create the account in Libra first with that exact name.
|
**Solution:** Create the account in Castle first with that exact name.
|
||||||
|
|
||||||
### "No user ID mapping found"
|
### "No user ID mapping found"
|
||||||
**Error:** `No user ID mapping found for 'Pat'`
|
**Error:** `No user ID mapping found for 'Pat'`
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Beancount to Libra Import Script
|
Beancount to Castle Import Script
|
||||||
|
|
||||||
⚠️ NOTE: This script is for ONE-OFF MIGRATION purposes only.
|
⚠️ NOTE: This script is for ONE-OFF MIGRATION purposes only.
|
||||||
|
|
||||||
Now that Libra uses Fava/Beancount as the single source of truth,
|
Now that Castle uses Fava/Beancount as the single source of truth,
|
||||||
the data flow is: Libra → Fava/Beancount (not the reverse).
|
the data flow is: Castle → Fava/Beancount (not the reverse).
|
||||||
|
|
||||||
This script was used for initial data import from existing Beancount files.
|
This script was used for initial data import from existing Beancount files.
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ Beancount to Libra Import Script
|
||||||
- REPURPOSE for bidirectional sync if that becomes a requirement
|
- REPURPOSE for bidirectional sync if that becomes a requirement
|
||||||
- ARCHIVE to misc-docs/old-helpers/ if keeping for reference
|
- ARCHIVE to misc-docs/old-helpers/ if keeping for reference
|
||||||
|
|
||||||
Imports Beancount ledger transactions into Libra accounting extension.
|
Imports Beancount ledger transactions into Castle accounting extension.
|
||||||
Reads daily BTC/EUR rates from btc_eur_rates.csv in the same directory.
|
Reads daily BTC/EUR rates from btc_eur_rates.csv in the same directory.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
@ -35,14 +35,14 @@ from typing import Dict, Optional
|
||||||
|
|
||||||
# LNbits URL and API Key
|
# LNbits URL and API Key
|
||||||
LNBITS_URL = os.environ.get("LNBITS_URL", "http://localhost:5000")
|
LNBITS_URL = os.environ.get("LNBITS_URL", "http://localhost:5000")
|
||||||
ADMIN_API_KEY = os.environ.get("LIBRA_ADMIN_KEY", "48d787d862484a6c89d6a557b4d5be9d")
|
ADMIN_API_KEY = os.environ.get("CASTLE_ADMIN_KEY", "48d787d862484a6c89d6a557b4d5be9d")
|
||||||
|
|
||||||
# Rates CSV file (looks in same directory as this script)
|
# Rates CSV file (looks in same directory as this script)
|
||||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
RATES_CSV_FILE = os.path.join(SCRIPT_DIR, "btc_eur_rates.csv")
|
RATES_CSV_FILE = os.path.join(SCRIPT_DIR, "btc_eur_rates.csv")
|
||||||
|
|
||||||
# User ID mappings: Equity account name -> Libra user ID (wallet ID)
|
# User ID mappings: Equity account name -> Castle user ID (wallet ID)
|
||||||
# TODO: Update these with your actual Libra user/wallet IDs
|
# TODO: Update these with your actual Castle user/wallet IDs
|
||||||
USER_MAPPINGS = {
|
USER_MAPPINGS = {
|
||||||
"Pat": "75be145a42884b22b60bf97510ed46e3",
|
"Pat": "75be145a42884b22b60bf97510ed46e3",
|
||||||
"Coco": "375ec158ceca46de86cf6561ca20f881",
|
"Coco": "375ec158ceca46de86cf6561ca20f881",
|
||||||
|
|
@ -116,7 +116,7 @@ class RateLookup:
|
||||||
# ===== ACCOUNT LOOKUP =====
|
# ===== ACCOUNT LOOKUP =====
|
||||||
|
|
||||||
class AccountLookup:
|
class AccountLookup:
|
||||||
"""Fetch and lookup Libra accounts from API"""
|
"""Fetch and lookup Castle accounts from API"""
|
||||||
|
|
||||||
def __init__(self, lnbits_url: str, api_key: str):
|
def __init__(self, lnbits_url: str, api_key: str):
|
||||||
self.accounts = {} # name -> account_id
|
self.accounts = {} # name -> account_id
|
||||||
|
|
@ -125,8 +125,8 @@ class AccountLookup:
|
||||||
self._fetch_accounts(lnbits_url, api_key)
|
self._fetch_accounts(lnbits_url, api_key)
|
||||||
|
|
||||||
def _fetch_accounts(self, lnbits_url: str, api_key: str):
|
def _fetch_accounts(self, lnbits_url: str, api_key: str):
|
||||||
"""Fetch all accounts from Libra API"""
|
"""Fetch all accounts from Castle API"""
|
||||||
url = f"{lnbits_url}/libra/api/v1/accounts"
|
url = f"{lnbits_url}/castle/api/v1/accounts"
|
||||||
headers = {"X-Api-Key": api_key}
|
headers = {"X-Api-Key": api_key}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -153,28 +153,28 @@ class AccountLookup:
|
||||||
self.accounts_by_user[user_id] = {}
|
self.accounts_by_user[user_id] = {}
|
||||||
self.accounts_by_user[user_id][account_type] = account_id
|
self.accounts_by_user[user_id][account_type] = account_id
|
||||||
|
|
||||||
print(f"🏦 Loaded {len(self.accounts)} accounts from Libra")
|
print(f"🏦 Loaded {len(self.accounts)} accounts from Castle")
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
raise ConnectionError(f"Failed to fetch accounts from Libra API: {e}")
|
raise ConnectionError(f"Failed to fetch accounts from Castle API: {e}")
|
||||||
|
|
||||||
def get_account_id(self, account_name: str) -> Optional[str]:
|
def get_account_id(self, account_name: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Get Libra account ID for a Beancount account name.
|
Get Castle account ID for a Beancount account name.
|
||||||
|
|
||||||
Special handling for user-specific accounts:
|
Special handling for user-specific accounts:
|
||||||
- "Liabilities:Payable:Pat" -> looks up Pat's user_id and finds their Libra payable account
|
- "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 Libra receivable 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 Libra equity account
|
- "Equity:Pat" -> looks up Pat's user_id and finds their Castle equity account
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
account_name: Beancount account name (e.g., "Expenses:Food:Supplies", "Liabilities:Payable:Pat", "Assets:Receivable:Pat", "Equity:Pat")
|
account_name: Beancount account name (e.g., "Expenses:Food:Supplies", "Liabilities:Payable:Pat", "Assets:Receivable:Pat", "Equity:Pat")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Libra account UUID or None if not found
|
Castle account UUID or None if not found
|
||||||
"""
|
"""
|
||||||
# Check if this is a Liabilities:Payable:<name> account
|
# Check if this is a Liabilities:Payable:<name> account
|
||||||
# Map Beancount Liabilities:Payable:Pat to Libra Liabilities:Payable:User-<id>
|
# Map Beancount Liabilities:Payable:Pat to Castle Liabilities:Payable:User-<id>
|
||||||
if account_name.startswith("Liabilities:Payable:"):
|
if account_name.startswith("Liabilities:Payable:"):
|
||||||
user_name = extract_user_from_user_account(account_name)
|
user_name = extract_user_from_user_account(account_name)
|
||||||
if user_name:
|
if user_name:
|
||||||
|
|
@ -182,7 +182,7 @@ class AccountLookup:
|
||||||
user_id = USER_MAPPINGS.get(user_name)
|
user_id = USER_MAPPINGS.get(user_name)
|
||||||
if user_id:
|
if user_id:
|
||||||
# Find this user's liability (payable) account
|
# Find this user's liability (payable) account
|
||||||
# This is the Liabilities:Payable:User-<id> account in Libra
|
# This is the Liabilities:Payable:User-<id> account in Castle
|
||||||
if user_id in self.accounts_by_user:
|
if user_id in self.accounts_by_user:
|
||||||
liability_account_id = self.accounts_by_user[user_id].get('liability')
|
liability_account_id = self.accounts_by_user[user_id].get('liability')
|
||||||
if liability_account_id:
|
if liability_account_id:
|
||||||
|
|
@ -196,7 +196,7 @@ class AccountLookup:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if this is an Assets:Receivable:<name> account
|
# Check if this is an Assets:Receivable:<name> account
|
||||||
# Map Beancount Assets:Receivable:Pat to Libra Assets:Receivable:User-<id>
|
# Map Beancount Assets:Receivable:Pat to Castle Assets:Receivable:User-<id>
|
||||||
elif account_name.startswith("Assets:Receivable:"):
|
elif account_name.startswith("Assets:Receivable:"):
|
||||||
user_name = extract_user_from_user_account(account_name)
|
user_name = extract_user_from_user_account(account_name)
|
||||||
if user_name:
|
if user_name:
|
||||||
|
|
@ -204,7 +204,7 @@ class AccountLookup:
|
||||||
user_id = USER_MAPPINGS.get(user_name)
|
user_id = USER_MAPPINGS.get(user_name)
|
||||||
if user_id:
|
if user_id:
|
||||||
# Find this user's asset (receivable) account
|
# Find this user's asset (receivable) account
|
||||||
# This is the Assets:Receivable:User-<id> account in Libra
|
# This is the Assets:Receivable:User-<id> account in Castle
|
||||||
if user_id in self.accounts_by_user:
|
if user_id in self.accounts_by_user:
|
||||||
asset_account_id = self.accounts_by_user[user_id].get('asset')
|
asset_account_id = self.accounts_by_user[user_id].get('asset')
|
||||||
if asset_account_id:
|
if asset_account_id:
|
||||||
|
|
@ -218,7 +218,7 @@ class AccountLookup:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if this is an Equity:<name> account
|
# Check if this is an Equity:<name> account
|
||||||
# Map Beancount Equity:Pat to Libra Equity:User-<id>
|
# Map Beancount Equity:Pat to Castle Equity:User-<id>
|
||||||
elif account_name.startswith("Equity:"):
|
elif account_name.startswith("Equity:"):
|
||||||
user_name = extract_user_from_user_account(account_name)
|
user_name = extract_user_from_user_account(account_name)
|
||||||
if user_name:
|
if user_name:
|
||||||
|
|
@ -226,7 +226,7 @@ class AccountLookup:
|
||||||
user_id = USER_MAPPINGS.get(user_name)
|
user_id = USER_MAPPINGS.get(user_name)
|
||||||
if user_id:
|
if user_id:
|
||||||
# Find this user's equity account
|
# Find this user's equity account
|
||||||
# This is the Equity:User-<id> account in Libra
|
# This is the Equity:User-<id> account in Castle
|
||||||
if user_id in self.accounts_by_user:
|
if user_id in self.accounts_by_user:
|
||||||
equity_account_id = self.accounts_by_user[user_id].get('equity')
|
equity_account_id = self.accounts_by_user[user_id].get('equity')
|
||||||
if equity_account_id:
|
if equity_account_id:
|
||||||
|
|
@ -235,7 +235,7 @@ class AccountLookup:
|
||||||
# If not found, provide helpful error
|
# If not found, provide helpful error
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"User '{user_name}' (ID: {user_id}) does not have an equity account.\n"
|
f"User '{user_name}' (ID: {user_id}) does not have an equity account.\n"
|
||||||
f"Equity eligibility must be enabled for this user in Libra.\n"
|
f"Equity eligibility must be enabled for this user in Castle.\n"
|
||||||
f"Please enable equity for user ID: {user_id}"
|
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:
|
def build_metadata(eur_amount: Decimal, btc_eur_rate: float) -> dict:
|
||||||
"""
|
"""
|
||||||
Build metadata dict for Libra entry line.
|
Build metadata dict for Castle entry line.
|
||||||
|
|
||||||
The API will extract fiat_currency and fiat_amount and use them
|
The API will extract fiat_currency and fiat_amount and use them
|
||||||
to create proper EUR-based postings with SATS in metadata.
|
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
|
# No user-specific account found - this shouldn't happen for typical transactions
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ===== LIBRA CONVERTER =====
|
# ===== CASTLE CONVERTER =====
|
||||||
|
|
||||||
def convert_to_libra_entry(parsed: dict, btc_eur_rate: float, account_lookup: AccountLookup) -> dict:
|
def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: AccountLookup) -> dict:
|
||||||
"""
|
"""
|
||||||
Convert parsed Beancount transaction to Libra format.
|
Convert parsed Beancount transaction to Castle format.
|
||||||
|
|
||||||
Sends SATS amounts with fiat metadata. The Libra API will automatically
|
Sends SATS amounts with fiat metadata. The Castle API will automatically
|
||||||
convert to EUR-based postings with SATS stored in metadata.
|
convert to EUR-based postings with SATS stored in metadata.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -469,8 +469,8 @@ def convert_to_libra_entry(parsed: dict, btc_eur_rate: float, account_lookup: Ac
|
||||||
account_id = account_lookup.get_account_id(posting['account'])
|
account_id = account_lookup.get_account_id(posting['account'])
|
||||||
if not account_id:
|
if not account_id:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"No account found in Libra with name '{posting['account']}'.\n"
|
f"No account found in Castle with name '{posting['account']}'.\n"
|
||||||
f"Please create this account in Libra first."
|
f"Please create this account in Castle first."
|
||||||
)
|
)
|
||||||
|
|
||||||
eur_amount = posting['eur_amount']
|
eur_amount = posting['eur_amount']
|
||||||
|
|
@ -510,7 +510,7 @@ def convert_to_libra_entry(parsed: dict, btc_eur_rate: float, account_lookup: Ac
|
||||||
# ===== API UPLOAD =====
|
# ===== API UPLOAD =====
|
||||||
|
|
||||||
def upload_entry(entry: dict, api_key: str, dry_run: bool = False) -> dict:
|
def upload_entry(entry: dict, api_key: str, dry_run: bool = False) -> dict:
|
||||||
"""Upload journal entry to Libra API"""
|
"""Upload journal entry to Castle API"""
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f"\n[DRY RUN] Entry preview:")
|
print(f"\n[DRY RUN] Entry preview:")
|
||||||
print(f" Description: {entry['description']}")
|
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)")
|
print(f" Balance check: {total_sats} (should be 0)")
|
||||||
return {"id": "dry-run"}
|
return {"id": "dry-run"}
|
||||||
|
|
||||||
url = f"{LNBITS_URL}/libra/api/v1/entries"
|
url = f"{LNBITS_URL}/castle/api/v1/entries"
|
||||||
headers = {
|
headers = {
|
||||||
"X-Api-Key": api_key,
|
"X-Api-Key": api_key,
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
|
|
@ -551,7 +551,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
|
||||||
|
|
||||||
# Validate configuration
|
# Validate configuration
|
||||||
if not ADMIN_API_KEY:
|
if not ADMIN_API_KEY:
|
||||||
print("❌ Error: LIBRA_ADMIN_KEY not set!")
|
print("❌ Error: CASTLE_ADMIN_KEY not set!")
|
||||||
print(" Set it as environment variable or update ADMIN_API_KEY in the script.")
|
print(" Set it as environment variable or update ADMIN_API_KEY in the script.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -562,7 +562,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
|
||||||
print(f"❌ Error loading rates: {e}")
|
print(f"❌ Error loading rates: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Load accounts from Libra
|
# Load accounts from Castle
|
||||||
try:
|
try:
|
||||||
account_lookup = AccountLookup(LNBITS_URL, ADMIN_API_KEY)
|
account_lookup = AccountLookup(LNBITS_URL, ADMIN_API_KEY)
|
||||||
except (ConnectionError, ValueError) as e:
|
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():
|
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]
|
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 "❌"
|
status = "✅" if has_equity else "❌"
|
||||||
print(f" {status} {name} → {user_id} {'(has equity account)' if has_equity else '(NO EQUITY ACCOUNT - create in Libra!)'}")
|
print(f" {status} {name} → {user_id} {'(has equity account)' if has_equity else '(NO EQUITY ACCOUNT - create in Castle!)'}")
|
||||||
|
|
||||||
# Read beancount file
|
# Read beancount file
|
||||||
if not os.path.exists(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:
|
if not btc_eur_rate:
|
||||||
raise ValueError(f"No BTC/EUR rate found for {parsed['date'].date()}")
|
raise ValueError(f"No BTC/EUR rate found for {parsed['date'].date()}")
|
||||||
|
|
||||||
libra_entry = convert_to_libra_entry(parsed, btc_eur_rate, account_lookup)
|
castle_entry = convert_to_castle_entry(parsed, btc_eur_rate, account_lookup)
|
||||||
result = upload_entry(libra_entry, ADMIN_API_KEY, dry_run)
|
result = upload_entry(castle_entry, ADMIN_API_KEY, dry_run)
|
||||||
|
|
||||||
# Get user name for display
|
# Get user name for display
|
||||||
user_name = None
|
user_name = None
|
||||||
|
|
@ -643,7 +643,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
|
||||||
print(f" {item}")
|
print(f" {item}")
|
||||||
|
|
||||||
if success_count > 0 and not dry_run:
|
if success_count > 0 and not dry_run:
|
||||||
print(f"\n✅ Successfully imported {success_count} transactions to Libra!")
|
print(f"\n✅ Successfully imported {success_count} transactions to Castle!")
|
||||||
print(f"\n💡 Note: Transactions are stored in EUR with SATS in metadata.")
|
print(f"\n💡 Note: Transactions are stored in EUR with SATS in metadata.")
|
||||||
print(f" Check Fava to see the imported entries.")
|
print(f" Check Fava to see the imported entries.")
|
||||||
|
|
||||||
|
|
@ -653,7 +653,7 @@ if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
print("⚖️ Beancount to Libra Import Script")
|
print("🏰 Beancount to Castle Import Script")
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
|
|
@ -664,7 +664,7 @@ if __name__ == "__main__":
|
||||||
print("\nConfiguration:")
|
print("\nConfiguration:")
|
||||||
print(f" LNBITS_URL: {LNBITS_URL}")
|
print(f" LNBITS_URL: {LNBITS_URL}")
|
||||||
print(f" RATES_CSV: {RATES_CSV_FILE}")
|
print(f" RATES_CSV: {RATES_CSV_FILE}")
|
||||||
print(f" API Key set: {'Yes' if ADMIN_API_KEY else 'No (set LIBRA_ADMIN_KEY env var)'}")
|
print(f" API Key set: {'Yes' if ADMIN_API_KEY else 'No (set CASTLE_ADMIN_KEY env var)'}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
beancount_file = sys.argv[1]
|
beancount_file = sys.argv[1]
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
{
|
{
|
||||||
"repos": [
|
"repos": [
|
||||||
{
|
{
|
||||||
"id": "libra",
|
"id": "castle",
|
||||||
"organisation": "lnbits",
|
"organisation": "lnbits",
|
||||||
"repository": "libra"
|
"repository": "castle"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""
|
"""
|
||||||
Libra Extension Database Migrations
|
Castle Extension Database Migrations
|
||||||
|
|
||||||
This file contains a single squashed migration that creates the complete
|
This file contains a single squashed migration that creates the complete
|
||||||
database schema for the Libra extension.
|
database schema for the Castle extension.
|
||||||
|
|
||||||
MIGRATION HISTORY:
|
MIGRATION HISTORY:
|
||||||
This is a squashed migration that combines m001-m016 from the original
|
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):
|
async def m001_initial(db):
|
||||||
"""
|
"""
|
||||||
Initial Libra database schema (squashed from m001-m016).
|
Initial Castle database schema (squashed from m001-m016).
|
||||||
|
|
||||||
Creates complete database structure for Libra accounting extension:
|
Creates complete database structure for Castle accounting extension:
|
||||||
- Accounts: Chart of accounts with hierarchical Beancount-style names
|
- Accounts: Chart of accounts with hierarchical Beancount-style names
|
||||||
- Extension settings: Libra-wide configuration
|
- Extension settings: Castle-wide configuration
|
||||||
- User wallet settings: Per-user wallet configuration
|
- User wallet settings: Per-user wallet configuration
|
||||||
- Manual payment requests: User-submitted payment requests to Libra
|
- Manual payment requests: User-submitted payment requests to Castle
|
||||||
- Balance assertions: Reconciliation and balance checking
|
- Balance assertions: Reconciliation and balance checking
|
||||||
- User equity status: Equity contribution eligibility
|
- User equity status: Equity contribution eligibility
|
||||||
- Account permissions: Granular access control
|
- Account permissions: Granular access control
|
||||||
|
|
||||||
Note: Journal entries are managed by Fava/Beancount (external source of truth).
|
Note: Journal entries are managed by Fava/Beancount (external source of truth).
|
||||||
Libra submits entries to Fava and queries Fava for journal data.
|
Castle submits entries to Fava and queries Fava for journal data.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -89,15 +89,15 @@ async def m001_initial(db):
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# EXTENSION SETTINGS TABLE
|
# EXTENSION SETTINGS TABLE
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Libra-wide configuration settings
|
# Castle-wide configuration settings
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
f"""
|
f"""
|
||||||
CREATE TABLE extension_settings (
|
CREATE TABLE extension_settings (
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
libra_wallet_id TEXT,
|
castle_wallet_id TEXT,
|
||||||
fava_url TEXT NOT NULL DEFAULT 'http://localhost:3333',
|
fava_url TEXT NOT NULL DEFAULT 'http://localhost:3333',
|
||||||
fava_ledger_slug TEXT NOT NULL DEFAULT 'libra-ledger',
|
fava_ledger_slug TEXT NOT NULL DEFAULT 'castle-ledger',
|
||||||
fava_timeout REAL NOT NULL DEFAULT 10.0,
|
fava_timeout REAL NOT NULL DEFAULT 10.0,
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||||
);
|
);
|
||||||
|
|
@ -122,7 +122,7 @@ async def m001_initial(db):
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# MANUAL PAYMENT REQUESTS TABLE
|
# MANUAL PAYMENT REQUESTS TABLE
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# User-submitted payment requests to Libra (reviewed by admins)
|
# User-submitted payment requests to Castle (reviewed by admins)
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
f"""
|
f"""
|
||||||
|
|
@ -240,7 +240,7 @@ async def m001_initial(db):
|
||||||
# ACCOUNT PERMISSIONS TABLE
|
# ACCOUNT PERMISSIONS TABLE
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Granular access control for accounts
|
# Granular access control for accounts
|
||||||
# Permission types: read, submit_expense, submit_income, manage
|
# Permission types: read, submit_expense, manage
|
||||||
# Supports hierarchical inheritance (parent account permissions cascade)
|
# Supports hierarchical inheritance (parent account permissions cascade)
|
||||||
|
|
||||||
await db.execute(
|
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.
|
Add is_virtual field to accounts table for virtual parent accounts.
|
||||||
|
|
||||||
Virtual parent accounts:
|
Virtual parent accounts:
|
||||||
- Exist only in Libra DB (metadata-only, not in Beancount)
|
- Exist only in Castle DB (metadata-only, not in Beancount)
|
||||||
- Used solely for permission inheritance
|
- Used solely for permission inheritance
|
||||||
- Allow granting permissions on top-level accounts like "Expenses", "Assets"
|
- Allow granting permissions on top-level accounts like "Expenses", "Assets"
|
||||||
- Are not synced to/from Beancount
|
- Are not synced to/from Beancount
|
||||||
|
|
|
||||||
64
models.py
64
models.py
|
|
@ -48,17 +48,6 @@ class CreateAccount(BaseModel):
|
||||||
is_virtual: bool = False # Set to True to create virtual parent account
|
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):
|
class EntryLine(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
journal_entry_id: str
|
journal_entry_id: str
|
||||||
|
|
@ -98,19 +87,9 @@ class CreateJournalEntry(BaseModel):
|
||||||
|
|
||||||
class UserBalance(BaseModel):
|
class UserBalance(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
balance: int # positive = libra owes user, negative = user owes libra
|
balance: int # positive = castle owes user, negative = user owes castle
|
||||||
accounts: list[Account] = []
|
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")}
|
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):
|
class ExpenseEntry(BaseModel):
|
||||||
|
|
@ -119,7 +98,7 @@ class ExpenseEntry(BaseModel):
|
||||||
description: str
|
description: str
|
||||||
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
||||||
expense_account: str # account name or ID
|
expense_account: str # account name or ID
|
||||||
is_equity: bool = False # True = equity contribution, False = liability (libra owes user)
|
is_equity: bool = False # True = equity contribution, False = liability (castle owes user)
|
||||||
user_wallet: str
|
user_wallet: str
|
||||||
reference: Optional[str] = None
|
reference: Optional[str] = None
|
||||||
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
|
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
|
||||||
|
|
@ -132,7 +111,7 @@ class ReceivableEntry(BaseModel):
|
||||||
description: str
|
description: str
|
||||||
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
||||||
revenue_account: str # account name or ID
|
revenue_account: str # account name or ID
|
||||||
user_id: str # The user_id (not wallet_id) of the user who owes the libra
|
user_id: str # The user_id (not wallet_id) of the user who owes the castle
|
||||||
reference: Optional[str] = None
|
reference: Optional[str] = None
|
||||||
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code
|
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code
|
||||||
|
|
||||||
|
|
@ -148,32 +127,14 @@ class RevenueEntry(BaseModel):
|
||||||
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code
|
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code
|
||||||
|
|
||||||
|
|
||||||
class IncomeEntry(BaseModel):
|
class CastleSettings(BaseModel):
|
||||||
"""Helper model for user-facing income/revenue submission (pending approval).
|
"""Settings for the Castle extension"""
|
||||||
|
|
||||||
The user records that they personally received money on the entity's
|
castle_wallet_id: Optional[str] = None # The wallet ID that represents the Castle
|
||||||
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/Beancount integration - ALL accounting is done via Fava
|
||||||
fava_url: str = "http://localhost:3333" # Base URL of Fava server
|
fava_url: str = "http://localhost:3333" # Base URL of Fava server
|
||||||
fava_ledger_slug: str = "libra-ledger" # Ledger identifier in Fava URL
|
fava_ledger_slug: str = "castle-ledger" # Ledger identifier in Fava URL
|
||||||
fava_timeout: float = 10.0 # Request timeout in seconds
|
fava_timeout: float = 10.0 # Request timeout in seconds
|
||||||
|
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now())
|
updated_at: datetime = Field(default_factory=lambda: datetime.now())
|
||||||
|
|
@ -183,7 +144,7 @@ class LibraSettings(BaseModel):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class UserLibraSettings(LibraSettings):
|
class UserCastleSettings(CastleSettings):
|
||||||
"""User-specific settings (stored with user_id)"""
|
"""User-specific settings (stored with user_id)"""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
|
|
@ -203,7 +164,7 @@ class StoredUserWalletSettings(UserWalletSettings):
|
||||||
|
|
||||||
|
|
||||||
class ManualPaymentRequest(BaseModel):
|
class ManualPaymentRequest(BaseModel):
|
||||||
"""Manual payment request from user to libra"""
|
"""Manual payment request from user to castle"""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
|
|
@ -212,7 +173,7 @@ class ManualPaymentRequest(BaseModel):
|
||||||
status: str = "pending" # pending, approved, rejected
|
status: str = "pending" # pending, approved, rejected
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
reviewed_at: Optional[datetime] = None
|
reviewed_at: Optional[datetime] = None
|
||||||
reviewed_by: Optional[str] = None # user_id of libra admin who reviewed
|
reviewed_by: Optional[str] = None # user_id of castle admin who reviewed
|
||||||
journal_entry_id: Optional[str] = None # set when approved
|
journal_entry_id: Optional[str] = None # set when approved
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -237,7 +198,7 @@ class RecordPayment(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class SettleReceivable(BaseModel):
|
class SettleReceivable(BaseModel):
|
||||||
"""Manually settle a receivable (user pays libra in person)"""
|
"""Manually settle a receivable (user pays castle in person)"""
|
||||||
|
|
||||||
user_id: str
|
user_id: str
|
||||||
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
||||||
|
|
@ -252,7 +213,7 @@ class SettleReceivable(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class PayUser(BaseModel):
|
class PayUser(BaseModel):
|
||||||
"""Pay a user (libra pays user for expense/liability)"""
|
"""Pay a user (castle pays user for expense/liability)"""
|
||||||
|
|
||||||
user_id: str
|
user_id: str
|
||||||
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
||||||
|
|
@ -334,7 +295,6 @@ class PermissionType(str, Enum):
|
||||||
"""Types of permissions for account access"""
|
"""Types of permissions for account access"""
|
||||||
READ = "read" # Can view account and its balance
|
READ = "read" # Can view account and its balance
|
||||||
SUBMIT_EXPENSE = "submit_expense" # Can submit expenses to this account
|
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)
|
MANAGE = "manage" # Can modify account (admin level)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "libra",
|
"name": "castle",
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"description": "Accounting for a collective entity",
|
"description": "Accounting for a collective entity",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|
|
||||||
|
|
@ -361,7 +361,7 @@ async def get_permission_analytics() -> dict:
|
||||||
"""
|
"""
|
||||||
SELECT ap.*, a.name as account_name
|
SELECT ap.*, a.name as account_name
|
||||||
FROM account_permissions ap
|
FROM account_permissions ap
|
||||||
JOIN libra_accounts a ON ap.account_id = a.id
|
JOIN castle_accounts a ON ap.account_id = a.id
|
||||||
WHERE ap.expires_at IS NOT NULL
|
WHERE ap.expires_at IS NOT NULL
|
||||||
AND ap.expires_at > :now
|
AND ap.expires_at > :now
|
||||||
AND ap.expires_at <= :seven_days
|
AND ap.expires_at <= :seven_days
|
||||||
|
|
@ -385,7 +385,7 @@ async def get_permission_analytics() -> dict:
|
||||||
top_accounts_result = await db.fetchall(
|
top_accounts_result = await db.fetchall(
|
||||||
"""
|
"""
|
||||||
SELECT a.name, COUNT(ap.id) as permission_count
|
SELECT a.name, COUNT(ap.id) as permission_count
|
||||||
FROM libra_accounts a
|
FROM castle_accounts a
|
||||||
LEFT JOIN account_permissions ap ON a.id = ap.account_id
|
LEFT JOIN account_permissions ap ON a.id = ap.account_id
|
||||||
GROUP BY a.id, a.name
|
GROUP BY a.id, a.name
|
||||||
HAVING COUNT(ap.id) > 0
|
HAVING COUNT(ap.id) > 0
|
||||||
|
|
|
||||||
39
services.py
39
services.py
|
|
@ -1,45 +1,28 @@
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_libra_settings,
|
create_castle_settings,
|
||||||
create_user_wallet_settings,
|
create_user_wallet_settings,
|
||||||
get_libra_settings,
|
get_castle_settings,
|
||||||
get_or_create_user_account,
|
get_or_create_user_account,
|
||||||
get_user_wallet_settings,
|
get_user_wallet_settings,
|
||||||
update_libra_settings,
|
update_castle_settings,
|
||||||
update_user_wallet_settings,
|
update_user_wallet_settings,
|
||||||
)
|
)
|
||||||
from .models import AccountType, LibraSettings, UserWalletSettings
|
from .models import AccountType, CastleSettings, UserWalletSettings
|
||||||
|
|
||||||
|
|
||||||
async def get_settings(user_id: str) -> LibraSettings:
|
async def get_settings(user_id: str) -> CastleSettings:
|
||||||
settings = await get_libra_settings(user_id)
|
settings = await get_castle_settings(user_id)
|
||||||
if not settings:
|
if not settings:
|
||||||
settings = await create_libra_settings(user_id, LibraSettings())
|
settings = await create_castle_settings(user_id, CastleSettings())
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
async def update_settings(user_id: str, data: LibraSettings) -> LibraSettings:
|
async def update_settings(user_id: str, data: CastleSettings) -> CastleSettings:
|
||||||
from loguru import logger
|
settings = await get_castle_settings(user_id)
|
||||||
|
|
||||||
from .fava_client import init_fava_client
|
|
||||||
|
|
||||||
settings = await get_libra_settings(user_id)
|
|
||||||
if not settings:
|
if not settings:
|
||||||
settings = await create_libra_settings(user_id, data)
|
settings = await create_castle_settings(user_id, data)
|
||||||
else:
|
else:
|
||||||
settings = await update_libra_settings(user_id, data)
|
settings = await update_castle_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
|
return settings
|
||||||
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
|
@ -31,10 +31,8 @@ window.app = Vue.createApp({
|
||||||
userInfo: null, // User information including equity eligibility
|
userInfo: null, // User information including equity eligibility
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
settingsLoaded: false, // Flag to prevent race conditions on toolbar buttons
|
castleWalletConfigured: false,
|
||||||
libraWalletConfigured: false,
|
|
||||||
userWalletConfigured: false,
|
userWalletConfigured: false,
|
||||||
syncingAccounts: false,
|
|
||||||
currentExchangeRate: null, // BTC/EUR rate (sats per EUR)
|
currentExchangeRate: null, // BTC/EUR rate (sats per EUR)
|
||||||
expenseDialog: {
|
expenseDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
|
|
@ -58,10 +56,7 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
settingsDialog: {
|
settingsDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
libraWalletId: '',
|
castleWalletId: '',
|
||||||
favaUrl: 'http://localhost:3333',
|
|
||||||
favaLedgerSlug: 'libra-ledger',
|
|
||||||
favaTimeout: 10.0,
|
|
||||||
loading: false
|
loading: false
|
||||||
},
|
},
|
||||||
userWalletDialog: {
|
userWalletDialog: {
|
||||||
|
|
@ -69,13 +64,6 @@ window.app = Vue.createApp({
|
||||||
userWalletId: '',
|
userWalletId: '',
|
||||||
loading: false
|
loading: false
|
||||||
},
|
},
|
||||||
addAccountDialog: {
|
|
||||||
show: false,
|
|
||||||
rootType: 'Expenses',
|
|
||||||
subPath: '',
|
|
||||||
description: '',
|
|
||||||
loading: false
|
|
||||||
},
|
|
||||||
receivableDialog: {
|
receivableDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
selectedUser: '',
|
selectedUser: '',
|
||||||
|
|
@ -215,8 +203,8 @@ window.app = Vue.createApp({
|
||||||
accountTypeOptions() {
|
accountTypeOptions() {
|
||||||
return [
|
return [
|
||||||
{ label: 'All Types', value: null },
|
{ label: 'All Types', value: null },
|
||||||
{ label: 'Receivable (User owes Libra)', value: 'asset' },
|
{ label: 'Receivable (User owes Castle)', value: 'asset' },
|
||||||
{ label: 'Payable (Libra owes User)', value: 'liability' },
|
{ label: 'Payable (Castle owes User)', value: 'liability' },
|
||||||
{ label: 'Equity (User Balance)', value: 'equity' }
|
{ label: 'Equity (User Balance)', value: 'equity' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -293,16 +281,6 @@ window.app = Vue.createApp({
|
||||||
})
|
})
|
||||||
return options
|
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() {
|
userOptions() {
|
||||||
const options = []
|
const options = []
|
||||||
this.users.forEach(user => {
|
this.users.forEach(user => {
|
||||||
|
|
@ -335,7 +313,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/balance',
|
'/castle/api/v1/balance',
|
||||||
this.g.user.wallets[0].inkey
|
this.g.user.wallets[0].inkey
|
||||||
)
|
)
|
||||||
this.balance = response.data
|
this.balance = response.data
|
||||||
|
|
@ -358,7 +336,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/balances/all',
|
'/castle/api/v1/balances/all',
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
this.allUserBalances = response.data
|
this.allUserBalances = response.data
|
||||||
|
|
@ -406,7 +384,7 @@ window.app = Vue.createApp({
|
||||||
|
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
`/libra/api/v1/entries/user?${queryParams}`,
|
`/castle/api/v1/entries/user?${queryParams}`,
|
||||||
this.g.user.wallets[0].inkey
|
this.g.user.wallets[0].inkey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -475,7 +453,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/accounts?filter_by_user=true&exclude_virtual=true',
|
'/castle/api/v1/accounts?filter_by_user=true&exclude_virtual=true',
|
||||||
this.g.user.wallets[0].inkey
|
this.g.user.wallets[0].inkey
|
||||||
)
|
)
|
||||||
this.accounts = response.data
|
this.accounts = response.data
|
||||||
|
|
@ -489,7 +467,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/currencies',
|
'/castle/api/v1/currencies',
|
||||||
this.g.user.wallets[0].inkey
|
this.g.user.wallets[0].inkey
|
||||||
)
|
)
|
||||||
this.currencies = response.data
|
this.currencies = response.data
|
||||||
|
|
@ -501,7 +479,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/users',
|
'/castle/api/v1/users',
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
this.users = response.data
|
this.users = response.data
|
||||||
|
|
@ -513,7 +491,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/user/info',
|
'/castle/api/v1/user/info',
|
||||||
this.g.user.wallets[0].inkey
|
this.g.user.wallets[0].inkey
|
||||||
)
|
)
|
||||||
this.userInfo = response.data
|
this.userInfo = response.data
|
||||||
|
|
@ -527,28 +505,25 @@ window.app = Vue.createApp({
|
||||||
// Try with admin key first to check settings
|
// Try with admin key first to check settings
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/settings',
|
'/castle/api/v1/settings',
|
||||||
this.g.user.wallets[0].inkey
|
this.g.user.wallets[0].inkey
|
||||||
)
|
)
|
||||||
this.settings = response.data
|
this.settings = response.data
|
||||||
this.libraWalletConfigured = !!(this.settings && this.settings.libra_wallet_id)
|
this.castleWalletConfigured = !!(this.settings && this.settings.castle_wallet_id)
|
||||||
|
|
||||||
// Check if user is super user by seeing if they can access admin features
|
// Check if user is super user by seeing if they can access admin features
|
||||||
this.isSuperUser = this.g.user.super_user || false
|
this.isSuperUser = this.g.user.super_user || false
|
||||||
this.isAdmin = this.g.user.admin || this.isSuperUser
|
this.isAdmin = this.g.user.admin || this.isSuperUser
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Settings not available
|
// Settings not available
|
||||||
this.libraWalletConfigured = false
|
this.castleWalletConfigured = false
|
||||||
} finally {
|
|
||||||
// Mark settings as loaded to enable toolbar buttons
|
|
||||||
this.settingsLoaded = true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async loadUserWallet() {
|
async loadUserWallet() {
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/user/wallet',
|
'/castle/api/v1/user/wallet',
|
||||||
this.g.user.wallets[0].inkey
|
this.g.user.wallets[0].inkey
|
||||||
)
|
)
|
||||||
this.userWalletSettings = response.data
|
this.userWalletSettings = response.data
|
||||||
|
|
@ -557,86 +532,8 @@ window.app = Vue.createApp({
|
||||||
this.userWalletConfigured = false
|
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() {
|
showSettingsDialog() {
|
||||||
this.settingsDialog.libraWalletId = this.settings?.libra_wallet_id || ''
|
this.settingsDialog.castleWalletId = this.settings?.castle_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
|
this.settingsDialog.show = true
|
||||||
},
|
},
|
||||||
showUserWalletDialog() {
|
showUserWalletDialog() {
|
||||||
|
|
@ -644,18 +541,10 @@ window.app = Vue.createApp({
|
||||||
this.userWalletDialog.show = true
|
this.userWalletDialog.show = true
|
||||||
},
|
},
|
||||||
async submitSettings() {
|
async submitSettings() {
|
||||||
if (!this.settingsDialog.libraWalletId) {
|
if (!this.settingsDialog.castleWalletId) {
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: 'Libra Wallet ID is required'
|
message: 'Castle Wallet ID is required'
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.settingsDialog.favaUrl) {
|
|
||||||
this.$q.notify({
|
|
||||||
type: 'warning',
|
|
||||||
message: 'Fava URL is required'
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -664,13 +553,10 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'PUT',
|
'PUT',
|
||||||
'/libra/api/v1/settings',
|
'/castle/api/v1/settings',
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
{
|
{
|
||||||
libra_wallet_id: this.settingsDialog.libraWalletId,
|
castle_wallet_id: this.settingsDialog.castleWalletId
|
||||||
fava_url: this.settingsDialog.favaUrl,
|
|
||||||
fava_ledger_slug: this.settingsDialog.favaLedgerSlug || 'libra-ledger',
|
|
||||||
fava_timeout: parseFloat(this.settingsDialog.favaTimeout) || 10.0
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
|
|
@ -679,7 +565,7 @@ window.app = Vue.createApp({
|
||||||
})
|
})
|
||||||
this.settingsDialog.show = false
|
this.settingsDialog.show = false
|
||||||
await this.loadSettings()
|
await this.loadSettings()
|
||||||
// Reload user wallet to reflect libra wallet for super user
|
// Reload user wallet to reflect castle wallet for super user
|
||||||
if (this.isSuperUser) {
|
if (this.isSuperUser) {
|
||||||
await this.loadUserWallet()
|
await this.loadUserWallet()
|
||||||
}
|
}
|
||||||
|
|
@ -702,7 +588,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'PUT',
|
'PUT',
|
||||||
'/libra/api/v1/user/wallet',
|
'/castle/api/v1/user/wallet',
|
||||||
this.g.user.wallets[0].inkey,
|
this.g.user.wallets[0].inkey,
|
||||||
{
|
{
|
||||||
user_wallet_id: this.userWalletDialog.userWalletId
|
user_wallet_id: this.userWalletDialog.userWalletId
|
||||||
|
|
@ -725,7 +611,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/entries/expense',
|
'/castle/api/v1/entries/expense',
|
||||||
this.g.user.wallets[0].inkey,
|
this.g.user.wallets[0].inkey,
|
||||||
{
|
{
|
||||||
description: this.expenseDialog.description,
|
description: this.expenseDialog.description,
|
||||||
|
|
@ -762,10 +648,10 @@ window.app = Vue.createApp({
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate an invoice on the Libra wallet
|
// Generate an invoice on the Castle wallet
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/generate-payment-invoice',
|
'/castle/api/v1/generate-payment-invoice',
|
||||||
this.g.user.wallets[0].inkey,
|
this.g.user.wallets[0].inkey,
|
||||||
{
|
{
|
||||||
amount: this.payDialog.amount
|
amount: this.payDialog.amount
|
||||||
|
|
@ -811,7 +697,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/record-payment',
|
'/castle/api/v1/record-payment',
|
||||||
this.g.user.wallets[0].inkey,
|
this.g.user.wallets[0].inkey,
|
||||||
{
|
{
|
||||||
payment_hash: paymentHash
|
payment_hash: paymentHash
|
||||||
|
|
@ -854,15 +740,15 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
showManualPaymentOption() {
|
showManualPaymentOption() {
|
||||||
// This is for when user wants to pay their debt manually
|
// This is for when user wants to pay their debt manually
|
||||||
// For now, just notify them to contact libra
|
// For now, just notify them to contact castle
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
message: 'Please contact Libra directly to arrange manual payment.',
|
message: 'Please contact Castle directly to arrange manual payment.',
|
||||||
timeout: 3000
|
timeout: 3000
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
showManualPaymentDialog() {
|
showManualPaymentDialog() {
|
||||||
// This is for when Libra owes user and they want to request manual payment
|
// This is for when Castle owes user and they want to request manual payment
|
||||||
this.manualPaymentDialog.amount = Math.abs(this.balance.balance)
|
this.manualPaymentDialog.amount = Math.abs(this.balance.balance)
|
||||||
this.manualPaymentDialog.description = ''
|
this.manualPaymentDialog.description = ''
|
||||||
this.manualPaymentDialog.show = true
|
this.manualPaymentDialog.show = true
|
||||||
|
|
@ -872,7 +758,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/manual-payment-request',
|
'/castle/api/v1/manual-payment-request',
|
||||||
this.g.user.wallets[0].inkey,
|
this.g.user.wallets[0].inkey,
|
||||||
{
|
{
|
||||||
amount: this.manualPaymentDialog.amount,
|
amount: this.manualPaymentDialog.amount,
|
||||||
|
|
@ -897,8 +783,8 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
// If super user, load all requests; otherwise load user's own requests
|
// If super user, load all requests; otherwise load user's own requests
|
||||||
const endpoint = this.isSuperUser
|
const endpoint = this.isSuperUser
|
||||||
? '/libra/api/v1/manual-payment-requests/all'
|
? '/castle/api/v1/manual-payment-requests/all'
|
||||||
: '/libra/api/v1/manual-payment-requests'
|
: '/castle/api/v1/manual-payment-requests'
|
||||||
const key = this.isSuperUser
|
const key = this.isSuperUser
|
||||||
? this.g.user.wallets[0].adminkey
|
? this.g.user.wallets[0].adminkey
|
||||||
: this.g.user.wallets[0].inkey
|
: this.g.user.wallets[0].inkey
|
||||||
|
|
@ -921,7 +807,7 @@ window.app = Vue.createApp({
|
||||||
|
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/entries/pending',
|
'/castle/api/v1/entries/pending',
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
this.pendingExpenses = response.data
|
this.pendingExpenses = response.data
|
||||||
|
|
@ -933,7 +819,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
`/libra/api/v1/manual-payment-requests/${requestId}/approve`,
|
`/castle/api/v1/manual-payment-requests/${requestId}/approve`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
|
|
@ -951,7 +837,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
`/libra/api/v1/manual-payment-requests/${requestId}/reject`,
|
`/castle/api/v1/manual-payment-requests/${requestId}/reject`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
|
|
@ -967,7 +853,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
`/libra/api/v1/entries/${entryId}/approve`,
|
`/castle/api/v1/entries/${entryId}/approve`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
|
|
@ -986,7 +872,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
`/libra/api/v1/entries/${entryId}/reject`,
|
`/castle/api/v1/entries/${entryId}/reject`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
|
|
@ -1005,7 +891,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/assertions',
|
'/castle/api/v1/assertions',
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
this.balanceAssertions = response.data
|
this.balanceAssertions = response.data
|
||||||
|
|
@ -1031,7 +917,7 @@ window.app = Vue.createApp({
|
||||||
|
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/assertions',
|
'/castle/api/v1/assertions',
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
payload
|
payload
|
||||||
)
|
)
|
||||||
|
|
@ -1080,7 +966,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
`/libra/api/v1/assertions/${assertionId}/check`,
|
`/castle/api/v1/assertions/${assertionId}/check`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1099,7 +985,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
`/libra/api/v1/assertions/${assertionId}`,
|
`/castle/api/v1/assertions/${assertionId}`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1128,7 +1014,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/reconciliation/summary',
|
'/castle/api/v1/reconciliation/summary',
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
this.reconciliation.summary = response.data
|
this.reconciliation.summary = response.data
|
||||||
|
|
@ -1142,7 +1028,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/reconciliation/discrepancies',
|
'/castle/api/v1/reconciliation/discrepancies',
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
this.reconciliation.discrepancies = response.data
|
this.reconciliation.discrepancies = response.data
|
||||||
|
|
@ -1155,7 +1041,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/reconciliation/check-all',
|
'/castle/api/v1/reconciliation/check-all',
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1209,7 +1095,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/entries/receivable',
|
'/castle/api/v1/entries/receivable',
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
{
|
{
|
||||||
description: this.receivableDialog.description,
|
description: this.receivableDialog.description,
|
||||||
|
|
@ -1252,7 +1138,7 @@ window.app = Vue.createApp({
|
||||||
this.receivableDialog.currency = null
|
this.receivableDialog.currency = null
|
||||||
},
|
},
|
||||||
async showSettleReceivableDialog(userBalance) {
|
async showSettleReceivableDialog(userBalance) {
|
||||||
// Only show for users who owe libra (positive balance = receivable)
|
// Only show for users who owe castle (positive balance = receivable)
|
||||||
if (userBalance.balance <= 0) return
|
if (userBalance.balance <= 0) return
|
||||||
|
|
||||||
// Clear any existing polling
|
// Clear any existing polling
|
||||||
|
|
@ -1268,19 +1154,19 @@ window.app = Vue.createApp({
|
||||||
// Fetch unsettled entries for this user (BOTH receivables AND expenses for net settlement)
|
// Fetch unsettled entries for this user (BOTH receivables AND expenses for net settlement)
|
||||||
let allEntryLinks = []
|
let allEntryLinks = []
|
||||||
try {
|
try {
|
||||||
// Fetch receivable entries (user owes libra)
|
// Fetch receivable entries (user owes castle)
|
||||||
const receivableResponse = await LNbits.api.request(
|
const receivableResponse = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
`/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
|
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
const receivableEntries = receivableResponse.data.unsettled_entries || []
|
const receivableEntries = receivableResponse.data.unsettled_entries || []
|
||||||
allEntryLinks.push(...receivableEntries.map(e => e.link).filter(l => l))
|
allEntryLinks.push(...receivableEntries.map(e => e.link).filter(l => l))
|
||||||
|
|
||||||
// Also fetch expense entries (libra owes user) - these are netted in the settlement
|
// Also fetch expense entries (castle owes user) - these are netted in the settlement
|
||||||
const expenseResponse = await LNbits.api.request(
|
const expenseResponse = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
`/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
|
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
const expenseEntries = expenseResponse.data.unsettled_entries || []
|
const expenseEntries = expenseResponse.data.unsettled_entries || []
|
||||||
|
|
@ -1320,10 +1206,10 @@ window.app = Vue.createApp({
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate an invoice on the Libra wallet for the user to pay
|
// Generate an invoice on the Castle wallet for the user to pay
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/generate-payment-invoice',
|
'/castle/api/v1/generate-payment-invoice',
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
{
|
{
|
||||||
amount: this.settleReceivableDialog.amount,
|
amount: this.settleReceivableDialog.amount,
|
||||||
|
|
@ -1450,7 +1336,7 @@ window.app = Vue.createApp({
|
||||||
|
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/receivables/settle',
|
'/castle/api/v1/receivables/settle',
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
payload
|
payload
|
||||||
)
|
)
|
||||||
|
|
@ -1474,7 +1360,7 @@ window.app = Vue.createApp({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async showPayUserDialog(userBalance) {
|
async showPayUserDialog(userBalance) {
|
||||||
// Only show for users libra owes (negative balance = payable)
|
// Only show for users castle owes (negative balance = payable)
|
||||||
if (userBalance.balance >= 0) return
|
if (userBalance.balance >= 0) return
|
||||||
|
|
||||||
// Extract fiat balances (e.g., EUR)
|
// Extract fiat balances (e.g., EUR)
|
||||||
|
|
@ -1482,26 +1368,26 @@ window.app = Vue.createApp({
|
||||||
const fiatCurrency = Object.keys(fiatBalances)[0] || null
|
const fiatCurrency = Object.keys(fiatBalances)[0] || null
|
||||||
const fiatAmount = fiatCurrency ? fiatBalances[fiatCurrency] : 0
|
const fiatAmount = fiatCurrency ? fiatBalances[fiatCurrency] : 0
|
||||||
|
|
||||||
// Use absolute values since balance is negative (liability = libra owes user)
|
// Use absolute values since balance is negative (liability = castle owes user)
|
||||||
const maxAmountSats = Math.abs(userBalance.balance)
|
const maxAmountSats = Math.abs(userBalance.balance)
|
||||||
const maxAmountFiat = Math.abs(fiatAmount)
|
const maxAmountFiat = Math.abs(fiatAmount)
|
||||||
|
|
||||||
// Fetch unsettled entries for this user (BOTH expenses AND receivables for net settlement)
|
// Fetch unsettled entries for this user (BOTH expenses AND receivables for net settlement)
|
||||||
let allEntryLinks = []
|
let allEntryLinks = []
|
||||||
try {
|
try {
|
||||||
// Fetch expense entries (libra owes user)
|
// Fetch expense entries (castle owes user)
|
||||||
const expenseResponse = await LNbits.api.request(
|
const expenseResponse = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
`/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
|
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
const expenseEntries = expenseResponse.data.unsettled_entries || []
|
const expenseEntries = expenseResponse.data.unsettled_entries || []
|
||||||
allEntryLinks.push(...expenseEntries.map(e => e.link).filter(l => l))
|
allEntryLinks.push(...expenseEntries.map(e => e.link).filter(l => l))
|
||||||
|
|
||||||
// Also fetch receivable entries (user owes libra) - these are netted in the settlement
|
// Also fetch receivable entries (user owes castle) - these are netted in the settlement
|
||||||
const receivableResponse = await LNbits.api.request(
|
const receivableResponse = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
`/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
|
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
const receivableEntries = receivableResponse.data.unsettled_entries || []
|
const receivableEntries = receivableResponse.data.unsettled_entries || []
|
||||||
|
|
@ -1514,10 +1400,10 @@ window.app = Vue.createApp({
|
||||||
show: true,
|
show: true,
|
||||||
user_id: userBalance.user_id,
|
user_id: userBalance.user_id,
|
||||||
username: userBalance.username,
|
username: userBalance.username,
|
||||||
maxAmount: maxAmountSats, // Positive sats amount libra owes
|
maxAmount: maxAmountSats, // Positive sats amount castle owes
|
||||||
maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive)
|
maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive)
|
||||||
fiatCurrency: fiatCurrency,
|
fiatCurrency: fiatCurrency,
|
||||||
amount: maxAmountSats, // Default to sats since lightning is the default payment method
|
amount: fiatCurrency ? maxAmountFiat : maxAmountSats, // Default to fiat if available
|
||||||
payment_method: 'lightning', // Default to lightning for paying
|
payment_method: 'lightning', // Default to lightning for paying
|
||||||
description: '',
|
description: '',
|
||||||
reference: '',
|
reference: '',
|
||||||
|
|
@ -1546,14 +1432,13 @@ window.app = Vue.createApp({
|
||||||
{
|
{
|
||||||
out: false,
|
out: false,
|
||||||
amount: this.payUserDialog.amount,
|
amount: this.payUserDialog.amount,
|
||||||
memo: `Payment from Libra to ${this.payUserDialog.username}`
|
memo: `Payment from Castle to ${this.payUserDialog.username}`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
console.log(invoiceResponse)
|
|
||||||
|
|
||||||
const paymentRequest = invoiceResponse.data.bolt11
|
const paymentRequest = invoiceResponse.data.payment_request
|
||||||
|
|
||||||
// Pay the invoice from Libra's wallet
|
// Pay the invoice from Castle's wallet
|
||||||
const paymentResponse = await LNbits.api.request(
|
const paymentResponse = await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
`/api/v1/payments`,
|
`/api/v1/payments`,
|
||||||
|
|
@ -1564,7 +1449,7 @@ window.app = Vue.createApp({
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Record the payment in Libra accounting
|
// Record the payment in Castle accounting
|
||||||
const payPayload = {
|
const payPayload = {
|
||||||
user_id: this.payUserDialog.user_id,
|
user_id: this.payUserDialog.user_id,
|
||||||
amount: this.payUserDialog.amount,
|
amount: this.payUserDialog.amount,
|
||||||
|
|
@ -1579,7 +1464,7 @@ window.app = Vue.createApp({
|
||||||
|
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/payables/pay',
|
'/castle/api/v1/payables/pay',
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
payPayload
|
payPayload
|
||||||
)
|
)
|
||||||
|
|
@ -1645,7 +1530,7 @@ window.app = Vue.createApp({
|
||||||
|
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/payables/pay',
|
'/castle/api/v1/payables/pay',
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
payload
|
payload
|
||||||
)
|
)
|
||||||
|
|
@ -1672,7 +1557,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
`/libra/api/v1/user-wallet/${userId}`,
|
`/castle/api/v1/user-wallet/${userId}`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
|
|
@ -1708,34 +1593,6 @@ window.app = Vue.createApp({
|
||||||
formatSats(amount) {
|
formatSats(amount) {
|
||||||
return new Intl.NumberFormat().format(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) {
|
formatFiat(amount, currency) {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
|
|
@ -1757,13 +1614,13 @@ window.app = Vue.createApp({
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
isReceivable(entry) {
|
isReceivable(entry) {
|
||||||
// Check if this is a receivable entry (user owes libra)
|
// Check if this is a receivable entry (user owes castle)
|
||||||
if (entry.tags && entry.tags.includes('receivable-entry')) return true
|
if (entry.tags && entry.tags.includes('receivable-entry')) return true
|
||||||
if (entry.account && entry.account.includes('Receivable')) return true
|
if (entry.account && entry.account.includes('Receivable')) return true
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
isPayable(entry) {
|
isPayable(entry) {
|
||||||
// Check if this is a payable entry (libra owes user)
|
// Check if this is a payable entry (castle owes user)
|
||||||
if (entry.tags && entry.tags.includes('expense-entry')) return true
|
if (entry.tags && entry.tags.includes('expense-entry')) return true
|
||||||
if (entry.account && entry.account.includes('Payable')) return true
|
if (entry.account && entry.account.includes('Payable')) return true
|
||||||
return false
|
return false
|
||||||
|
|
@ -1773,10 +1630,6 @@ window.app = Vue.createApp({
|
||||||
if (entry.tags && entry.tags.includes('equity-contribution')) return true
|
if (entry.tags && entry.tags.includes('equity-contribution')) return true
|
||||||
if (entry.account && entry.account.includes('Equity')) return true
|
if (entry.account && entry.account.includes('Equity')) return true
|
||||||
return false
|
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() {
|
async created() {
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,6 @@ window.app = Vue.createApp({
|
||||||
label: 'Submit Expense',
|
label: 'Submit Expense',
|
||||||
description: 'Submit expenses to this account'
|
description: 'Submit expenses to this account'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: 'submit_income',
|
|
||||||
label: 'Submit Income',
|
|
||||||
description: 'Submit income/revenue entries to this account'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: 'manage',
|
value: 'manage',
|
||||||
label: 'Manage',
|
label: 'Manage',
|
||||||
|
|
@ -211,7 +206,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/admin/permissions',
|
'/castle/api/v1/admin/permissions',
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
this.permissions = response.data
|
this.permissions = response.data
|
||||||
|
|
@ -233,7 +228,7 @@ window.app = Vue.createApp({
|
||||||
// Admin permissions UI needs to see virtual accounts to grant permissions on them
|
// Admin permissions UI needs to see virtual accounts to grant permissions on them
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/accounts?exclude_virtual=false',
|
'/castle/api/v1/accounts?exclude_virtual=false',
|
||||||
this.g.user.wallets[0].inkey
|
this.g.user.wallets[0].inkey
|
||||||
)
|
)
|
||||||
this.accounts = response.data
|
this.accounts = response.data
|
||||||
|
|
@ -256,7 +251,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/admin/libra-users',
|
'/castle/api/v1/admin/castle-users',
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
this.users = response.data || []
|
this.users = response.data || []
|
||||||
|
|
@ -323,7 +318,7 @@ window.app = Vue.createApp({
|
||||||
|
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/admin/permissions',
|
'/castle/api/v1/admin/permissions',
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
payload
|
payload
|
||||||
)
|
)
|
||||||
|
|
@ -362,7 +357,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
`/libra/api/v1/admin/permissions/${this.permissionToRevoke.id}`,
|
`/castle/api/v1/admin/permissions/${this.permissionToRevoke.id}`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -433,7 +428,7 @@ window.app = Vue.createApp({
|
||||||
|
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/admin/permissions/bulk-grant',
|
'/castle/api/v1/admin/permissions/bulk-grant',
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
payload
|
payload
|
||||||
)
|
)
|
||||||
|
|
@ -506,8 +501,6 @@ window.app = Vue.createApp({
|
||||||
return 'blue'
|
return 'blue'
|
||||||
case 'submit_expense':
|
case 'submit_expense':
|
||||||
return 'green'
|
return 'green'
|
||||||
case 'submit_income':
|
|
||||||
return 'teal'
|
|
||||||
case 'manage':
|
case 'manage':
|
||||||
return 'red'
|
return 'red'
|
||||||
default:
|
default:
|
||||||
|
|
@ -521,8 +514,6 @@ window.app = Vue.createApp({
|
||||||
return 'visibility'
|
return 'visibility'
|
||||||
case 'submit_expense':
|
case 'submit_expense':
|
||||||
return 'add_circle'
|
return 'add_circle'
|
||||||
case 'submit_income':
|
|
||||||
return 'payments'
|
|
||||||
case 'manage':
|
case 'manage':
|
||||||
return 'admin_panel_settings'
|
return 'admin_panel_settings'
|
||||||
default:
|
default:
|
||||||
|
|
@ -544,7 +535,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/admin/equity-eligibility',
|
'/castle/api/v1/admin/equity-eligibility',
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
this.equityEligibleUsers = response.data || []
|
this.equityEligibleUsers = response.data || []
|
||||||
|
|
@ -582,7 +573,7 @@ window.app = Vue.createApp({
|
||||||
|
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/admin/equity-eligibility',
|
'/castle/api/v1/admin/equity-eligibility',
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
payload
|
payload
|
||||||
)
|
)
|
||||||
|
|
@ -621,7 +612,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
`/libra/api/v1/admin/equity-eligibility/${this.equityToRevoke.user_id}`,
|
`/castle/api/v1/admin/equity-eligibility/${this.equityToRevoke.user_id}`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -664,7 +655,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/admin/roles',
|
'/castle/api/v1/admin/roles',
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
this.roles = response.data || []
|
this.roles = response.data || []
|
||||||
|
|
@ -687,7 +678,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
`/libra/api/v1/admin/roles/${role.id}`,
|
`/castle/api/v1/admin/roles/${role.id}`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -709,7 +700,7 @@ window.app = Vue.createApp({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async editRole(role) {
|
editRole(role) {
|
||||||
this.editingRole = true
|
this.editingRole = true
|
||||||
this.selectedRole = role
|
this.selectedRole = role
|
||||||
this.roleForm = {
|
this.roleForm = {
|
||||||
|
|
@ -717,28 +708,6 @@ window.app = Vue.createApp({
|
||||||
description: role.description || '',
|
description: role.description || '',
|
||||||
is_default: role.is_default || false
|
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
|
this.showCreateRoleDialog = true
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -764,7 +733,7 @@ window.app = Vue.createApp({
|
||||||
// Update existing role
|
// Update existing role
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'PUT',
|
'PUT',
|
||||||
`/libra/api/v1/admin/roles/${this.selectedRole.id}`,
|
`/castle/api/v1/admin/roles/${this.selectedRole.id}`,
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
payload
|
payload
|
||||||
)
|
)
|
||||||
|
|
@ -778,7 +747,7 @@ window.app = Vue.createApp({
|
||||||
// Create new role
|
// Create new role
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/admin/roles',
|
'/castle/api/v1/admin/roles',
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
payload
|
payload
|
||||||
)
|
)
|
||||||
|
|
@ -817,7 +786,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
`/libra/api/v1/admin/roles/${this.roleToDelete.id}`,
|
`/castle/api/v1/admin/roles/${this.roleToDelete.id}`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -846,8 +815,6 @@ window.app = Vue.createApp({
|
||||||
this.showCreateRoleDialog = false
|
this.showCreateRoleDialog = false
|
||||||
this.editingRole = false
|
this.editingRole = false
|
||||||
this.selectedRole = null
|
this.selectedRole = null
|
||||||
this.roleUsersForView = []
|
|
||||||
this.rolePermissionsForView = []
|
|
||||||
this.resetRoleForm()
|
this.resetRoleForm()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -895,7 +862,7 @@ window.app = Vue.createApp({
|
||||||
|
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/libra/api/v1/admin/user-roles',
|
'/castle/api/v1/admin/user-roles',
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
payload
|
payload
|
||||||
)
|
)
|
||||||
|
|
@ -953,7 +920,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/libra/api/v1/admin/users/roles',
|
'/castle/api/v1/admin/users/roles',
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1017,7 +984,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
`/libra/api/v1/admin/user-roles/${this.userRoleToRevoke.id}`,
|
`/castle/api/v1/admin/user-roles/${this.userRoleToRevoke.id}`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1066,7 +1033,7 @@ window.app = Vue.createApp({
|
||||||
}
|
}
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
`/libra/api/v1/admin/roles/${this.selectedRole.id}/permissions`,
|
`/castle/api/v1/admin/roles/${this.selectedRole.id}/permissions`,
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
payload
|
payload
|
||||||
)
|
)
|
||||||
|
|
@ -1100,7 +1067,7 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
`/libra/api/v1/admin/roles/${this.selectedRole.id}/permissions/${permissionId}`,
|
`/castle/api/v1/admin/roles/${this.selectedRole.id}/permissions/${permissionId}`,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
// Reload role permissions
|
// Reload role permissions
|
||||||
|
|
@ -1151,3 +1118,5 @@ window.app = Vue.createApp({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
window.app.mount('#vue')
|
||||||
|
|
|
||||||
295
tasks.py
295
tasks.py
|
|
@ -1,5 +1,5 @@
|
||||||
"""
|
"""
|
||||||
Background tasks for Libra accounting extension.
|
Background tasks for Castle accounting extension.
|
||||||
These tasks handle automated reconciliation checks and maintenance.
|
These tasks handle automated reconciliation checks and maintenance.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -62,11 +62,11 @@ async def check_all_balance_assertions() -> dict:
|
||||||
|
|
||||||
# Log results
|
# Log results
|
||||||
if results["failed"] > 0:
|
if results["failed"] > 0:
|
||||||
print(f"[LIBRA] Daily reconciliation check: {results['failed']} FAILED assertions!")
|
print(f"[CASTLE] Daily reconciliation check: {results['failed']} FAILED assertions!")
|
||||||
for failed in results["failed_assertions"]:
|
for failed in results["failed_assertions"]:
|
||||||
print(f" - Account {failed['account_id']}: expected {failed['expected_sats']}, got {failed['actual_sats']}")
|
print(f" - Account {failed['account_id']}: expected {failed['expected_sats']}, got {failed['actual_sats']}")
|
||||||
else:
|
else:
|
||||||
print(f"[LIBRA] Daily reconciliation check: All {results['passed']} assertions passed ✓")
|
print(f"[CASTLE] Daily reconciliation check: All {results['passed']} assertions passed ✓")
|
||||||
|
|
||||||
return results
|
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.)
|
This function is meant to be called by a scheduler (cron, systemd timer, etc.)
|
||||||
or by LNbits background task system.
|
or by LNbits background task system.
|
||||||
"""
|
"""
|
||||||
print(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}")
|
print(f"[CASTLE] Running scheduled daily reconciliation check at {datetime.now()}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = await check_all_balance_assertions()
|
results = await check_all_balance_assertions()
|
||||||
|
|
@ -86,38 +86,38 @@ async def scheduled_daily_reconciliation():
|
||||||
# TODO: Send notifications if there are failures
|
# TODO: Send notifications if there are failures
|
||||||
# This could send email, webhook, or in-app notification
|
# This could send email, webhook, or in-app notification
|
||||||
if results["failed"] > 0:
|
if results["failed"] > 0:
|
||||||
print(f"[LIBRA] WARNING: {results['failed']} balance assertions failed!")
|
print(f"[CASTLE] WARNING: {results['failed']} balance assertions failed!")
|
||||||
# Future: Send alert notification
|
# Future: Send alert notification
|
||||||
|
|
||||||
return results
|
return results
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[LIBRA] Error in scheduled reconciliation: {e}")
|
print(f"[CASTLE] Error in scheduled reconciliation: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def scheduled_account_sync():
|
async def scheduled_account_sync():
|
||||||
"""
|
"""
|
||||||
Scheduled task that runs hourly to sync accounts from Beancount to Libra DB.
|
Scheduled task that runs hourly to sync accounts from Beancount to Castle DB.
|
||||||
|
|
||||||
This ensures Libra DB stays in sync with Beancount (source of truth) by
|
This ensures Castle DB stays in sync with Beancount (source of truth) by
|
||||||
automatically adding any new accounts created in Beancount to Libra's
|
automatically adding any new accounts created in Beancount to Castle's
|
||||||
metadata database for permission tracking.
|
metadata database for permission tracking.
|
||||||
"""
|
"""
|
||||||
from .account_sync import sync_accounts_from_beancount
|
from .account_sync import sync_accounts_from_beancount
|
||||||
|
|
||||||
logger.info(f"[LIBRA] Running scheduled account sync at {datetime.now()}")
|
logger.info(f"[CASTLE] Running scheduled account sync at {datetime.now()}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stats = await sync_accounts_from_beancount(force_full_sync=False)
|
stats = await sync_accounts_from_beancount(force_full_sync=False)
|
||||||
|
|
||||||
if stats["accounts_added"] > 0:
|
if stats["accounts_added"] > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[LIBRA] Account sync: Added {stats['accounts_added']} new accounts"
|
f"[CASTLE] Account sync: Added {stats['accounts_added']} new accounts"
|
||||||
)
|
)
|
||||||
|
|
||||||
if stats["errors"]:
|
if stats["errors"]:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[LIBRA] Account sync: {len(stats['errors'])} errors encountered"
|
f"[CASTLE] Account sync: {len(stats['errors'])} errors encountered"
|
||||||
)
|
)
|
||||||
for error in stats["errors"][:5]: # Log first 5 errors
|
for error in stats["errors"][:5]: # Log first 5 errors
|
||||||
logger.error(f" - {error}")
|
logger.error(f" - {error}")
|
||||||
|
|
@ -125,31 +125,24 @@ async def scheduled_account_sync():
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[LIBRA] Error in scheduled account sync: {e}")
|
logger.error(f"[CASTLE] Error in scheduled account sync: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_account_sync():
|
async def wait_for_account_sync():
|
||||||
"""
|
"""
|
||||||
Background task that periodically syncs accounts from Beancount to Libra DB.
|
Background task that periodically syncs accounts from Beancount to Castle DB.
|
||||||
|
|
||||||
Runs hourly to ensure Libra DB stays in sync with Beancount.
|
Runs hourly to ensure Castle 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".
|
|
||||||
"""
|
"""
|
||||||
from .fava_client import wait_for_fava_client
|
logger.info("[CASTLE] Account sync background task started")
|
||||||
|
|
||||||
logger.info("[LIBRA] Account sync background task started")
|
|
||||||
await wait_for_fava_client()
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Run sync
|
# Run sync
|
||||||
await scheduled_account_sync()
|
await scheduled_account_sync()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[LIBRA] Account sync error: {e}")
|
logger.error(f"[CASTLE] Account sync error: {e}")
|
||||||
|
|
||||||
# Wait 1 hour before next sync
|
# Wait 1 hour before next sync
|
||||||
await asyncio.sleep(3600) # 3600 seconds = 1 hour
|
await asyncio.sleep(3600) # 3600 seconds = 1 hour
|
||||||
|
|
@ -164,9 +157,9 @@ def start_daily_reconciliation_task():
|
||||||
|
|
||||||
For cron setup:
|
For cron setup:
|
||||||
# Run daily at 2 AM
|
# Run daily at 2 AM
|
||||||
0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
|
0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
|
||||||
"""
|
"""
|
||||||
print("[LIBRA] Daily reconciliation task registered")
|
print("[CASTLE] Daily reconciliation task registered")
|
||||||
# In a production system, you would register this with LNbits task scheduler
|
# In a production system, you would register this with LNbits task scheduler
|
||||||
# For now, it can be triggered manually via API endpoint
|
# For now, it can be triggered manually via API endpoint
|
||||||
|
|
||||||
|
|
@ -180,7 +173,7 @@ async def wait_for_paid_invoices():
|
||||||
before the payment is detected by client-side polling.
|
before the payment is detected by client-side polling.
|
||||||
"""
|
"""
|
||||||
invoice_queue = Queue()
|
invoice_queue = Queue()
|
||||||
register_invoice_listener(invoice_queue, "ext_libra")
|
register_invoice_listener(invoice_queue, "ext_castle")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
payment = await invoice_queue.get()
|
payment = await invoice_queue.get()
|
||||||
|
|
@ -189,141 +182,149 @@ async def wait_for_paid_invoices():
|
||||||
|
|
||||||
async def on_invoice_paid(payment: Payment) -> None:
|
async def on_invoice_paid(payment: Payment) -> None:
|
||||||
"""
|
"""
|
||||||
Handle a paid Libra invoice by automatically submitting to Fava.
|
Handle a paid Castle invoice by automatically submitting to Fava.
|
||||||
|
|
||||||
This function is called automatically when any invoice on the Libra wallet
|
This function is called automatically when any invoice on the Castle wallet
|
||||||
is paid. It checks if the invoice is a Libra payment and records it in
|
is paid. It checks if the invoice is a Castle payment and records it in
|
||||||
Beancount via Fava.
|
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 Libra-specific payments
|
# Only process Castle-specific payments
|
||||||
if not payment.extra or payment.extra.get("tag") != "libra":
|
if not payment.extra or payment.extra.get("tag") != "castle":
|
||||||
return
|
return
|
||||||
|
|
||||||
user_id = payment.extra.get("user_id")
|
user_id = payment.extra.get("user_id")
|
||||||
if not user_id:
|
if not user_id:
|
||||||
logger.warning(f"Libra invoice {payment.payment_hash} missing user_id in metadata")
|
logger.warning(f"Castle invoice {payment.payment_hash} missing user_id in metadata")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Check if payment already recorded (idempotency)
|
||||||
|
# Query Fava for existing entry with this payment hash link
|
||||||
from .fava_client import get_fava_client
|
from .fava_client import get_fava_client
|
||||||
|
import httpx
|
||||||
|
|
||||||
fava = get_fava_client()
|
fava = get_fava_client()
|
||||||
|
|
||||||
# Use idempotency key based on payment hash - this ensures duplicate
|
try:
|
||||||
# processing of the same payment won't create duplicate entries
|
# Check if payment already recorded by fetching recent entries
|
||||||
idempotency_key = f"ln-{payment.payment_hash[:16]}"
|
# 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]}"
|
||||||
|
|
||||||
# Acquire per-user lock to serialize processing for this user
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
# This prevents race conditions when a user has multiple payments being processed
|
# Get recent entries from Fava's journal endpoint
|
||||||
user_lock = fava.get_user_lock(user_id)
|
response = await client.get(
|
||||||
|
f"{fava.base_url}/api/journal",
|
||||||
|
params={"time": ""} # Get all entries
|
||||||
|
)
|
||||||
|
|
||||||
async with user_lock:
|
if response.status_code == 200:
|
||||||
logger.info(f"Recording Libra payment {payment.payment_hash} for user {user_id[:8]} to Fava")
|
data = response.json()
|
||||||
|
entries = data.get('entries', [])
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
from decimal import Decimal
|
unsettled_expenses = await fava.get_unsettled_entries_bql(user_id, "expense")
|
||||||
from .crud import get_account_by_name, get_or_create_user_account
|
settled_links.extend([e["link"] for e in unsettled_expenses if e.get("link")])
|
||||||
from .models import AccountType
|
unsettled_receivables = await fava.get_unsettled_entries_bql(user_id, "receivable")
|
||||||
from .beancount_format import format_net_settlement_entry
|
settled_links.extend([e["link"] for e in unsettled_receivables if e.get("link")])
|
||||||
|
|
||||||
# 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
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error(f"Error recording Libra payment {payment.payment_hash}: {e}")
|
logger.warning(f"Could not query unsettled entries for settlement links: {e}")
|
||||||
raise
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{{ window_vars(user) }}
|
{{ window_vars(user) }}
|
||||||
<script src="{{ static_url_for('libra/static', path='js/index.js') }}"></script>
|
<script src="{{ static_url_for('castle/static', path='js/index.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block page %}
|
{% block page %}
|
||||||
|
|
@ -13,22 +13,18 @@
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="row items-center no-wrap">
|
<div class="row items-center no-wrap">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h5 class="q-my-none">Libra</h5>
|
<h5 class="q-my-none">🏰 Castle Accounting</h5>
|
||||||
<p class="q-mb-none">Track expenses, receivables, and balances for the collective</p>
|
<p class="q-mb-none">Track expenses, receivables, and balances for the collective</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto q-gutter-xs">
|
<div class="col-auto q-gutter-xs">
|
||||||
<!-- Wait for settings to load before showing role-specific buttons to prevent race conditions -->
|
<q-btn v-if="!isSuperUser" flat round icon="account_balance_wallet" @click="showUserWalletDialog">
|
||||||
<q-btn v-if="settingsLoaded && !isSuperUser" flat round icon="account_balance_wallet" @click="showUserWalletDialog">
|
|
||||||
<q-tooltip>Configure Your Wallet</q-tooltip>
|
<q-tooltip>Configure Your Wallet</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn v-if="settingsLoaded && isSuperUser" flat round icon="admin_panel_settings" :href="'/libra/permissions'">
|
<q-btn v-if="isSuperUser" flat round icon="admin_panel_settings" :href="'/castle/permissions'">
|
||||||
<q-tooltip>Manage Permissions (Admin)</q-tooltip>
|
<q-tooltip>Manage Permissions (Admin)</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn v-if="settingsLoaded && isSuperUser" flat round icon="sync" :loading="syncingAccounts" @click="syncAccounts">
|
<q-btn v-if="isSuperUser" flat round icon="settings" @click="showSettingsDialog">
|
||||||
<q-tooltip>Sync Accounts from Beancount</q-tooltip>
|
<q-tooltip>Castle Settings (Super User Only)</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>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -36,19 +32,19 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<!-- Setup Warning -->
|
<!-- Setup Warning -->
|
||||||
<q-banner v-if="settingsLoaded && !libraWalletConfigured && isSuperUser" class="bg-warning text-white" rounded>
|
<q-banner v-if="!castleWalletConfigured && isSuperUser" class="bg-warning text-white" rounded>
|
||||||
<template v-slot:avatar>
|
<template v-slot:avatar>
|
||||||
<q-icon name="warning" color="white"></q-icon>
|
<q-icon name="warning" color="white"></q-icon>
|
||||||
</template>
|
</template>
|
||||||
<div>
|
<div>
|
||||||
<strong>Setup Required:</strong> Libra Wallet ID must be configured before the extension can function.
|
<strong>Setup Required:</strong> Castle Wallet ID must be configured before the extension can function.
|
||||||
</div>
|
</div>
|
||||||
<template v-slot:action>
|
<template v-slot:action>
|
||||||
<q-btn flat color="white" label="Configure Now" @click="showSettingsDialog"></q-btn>
|
<q-btn flat color="white" label="Configure Now" @click="showSettingsDialog"></q-btn>
|
||||||
</template>
|
</template>
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<q-banner v-if="settingsLoaded && !libraWalletConfigured && !isSuperUser" class="bg-info text-white" rounded>
|
<q-banner v-if="!castleWalletConfigured && !isSuperUser" class="bg-info text-white" rounded>
|
||||||
<template v-slot:avatar>
|
<template v-slot:avatar>
|
||||||
<q-icon name="info" color="white"></q-icon>
|
<q-icon name="info" color="white"></q-icon>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -57,7 +53,7 @@
|
||||||
</div>
|
</div>
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<q-banner v-if="settingsLoaded && libraWalletConfigured && !userWalletConfigured && !isSuperUser" class="bg-orange text-white" rounded>
|
<q-banner v-if="castleWalletConfigured && !userWalletConfigured && !isSuperUser" class="bg-orange text-white" rounded>
|
||||||
<template v-slot:avatar>
|
<template v-slot:avatar>
|
||||||
<q-icon name="account_balance_wallet" color="white"></q-icon>
|
<q-icon name="account_balance_wallet" color="white"></q-icon>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -69,19 +65,18 @@
|
||||||
</template>
|
</template>
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<!-- Pending Entries (Super User Only) -->
|
<!-- Pending Expense Entries (Super User Only) -->
|
||||||
<q-card v-if="isSuperUser && pendingExpenses.length > 0">
|
<q-card v-if="isSuperUser && pendingExpenses.length > 0">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h6 class="q-my-none q-mb-md">Pending Approvals</h6>
|
<h6 class="q-my-none q-mb-md">Pending Expense Approvals</h6>
|
||||||
<q-list separator>
|
<q-list separator>
|
||||||
<q-item v-for="entry in pendingExpenses" :key="entry.id">
|
<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-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>{% raw %}{{ entry.description }}{% endraw %}</q-item-label>
|
||||||
<q-item-label caption>
|
<q-item-label caption>
|
||||||
{% raw %}{{ formatDate(entry.entry_date) }}{% endraw %}
|
{% raw %}{{ formatDate(entry.entry_date) }}{% endraw %}
|
||||||
|
|
@ -132,13 +127,13 @@
|
||||||
<q-btn
|
<q-btn
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="expenseDialog.show = true"
|
@click="expenseDialog.show = true"
|
||||||
:disable="!libraWalletConfigured || (!userWalletConfigured && !isSuperUser)"
|
:disable="!castleWalletConfigured || (!userWalletConfigured && !isSuperUser)"
|
||||||
>
|
>
|
||||||
Add Expense
|
Add Expense
|
||||||
<q-tooltip v-if="!libraWalletConfigured">
|
<q-tooltip v-if="!castleWalletConfigured">
|
||||||
Libra wallet must be configured first
|
Castle wallet must be configured first
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
<q-tooltip v-if="libraWalletConfigured && !userWalletConfigured && !isSuperUser">
|
<q-tooltip v-if="castleWalletConfigured && !userWalletConfigured && !isSuperUser">
|
||||||
You must configure your wallet first
|
You must configure your wallet first
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
|
@ -146,14 +141,14 @@
|
||||||
v-if="isSuperUser"
|
v-if="isSuperUser"
|
||||||
color="orange"
|
color="orange"
|
||||||
@click="showReceivableDialog"
|
@click="showReceivableDialog"
|
||||||
:disable="!libraWalletConfigured"
|
:disable="!castleWalletConfigured"
|
||||||
>
|
>
|
||||||
Add Receivable
|
Add Receivable
|
||||||
<q-tooltip v-if="!libraWalletConfigured">
|
<q-tooltip v-if="!castleWalletConfigured">
|
||||||
Libra wallet must be configured first
|
Castle wallet must be configured first
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
<q-tooltip v-else>
|
<q-tooltip v-else>
|
||||||
Record when a user owes the organization
|
Record when a user owes the Castle
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn color="secondary" @click="loadTransactions">
|
<q-btn color="secondary" @click="loadTransactions">
|
||||||
|
|
@ -187,33 +182,22 @@
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:body-cell-balance="props">
|
<template v-slot:body-cell-balance="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
<!-- User owes you (org), per currency -->
|
<div :class="props.row.balance > 0 ? 'text-positive' : 'text-negative'">
|
||||||
<div v-if="hasOwesYouFiat(props.row.fiat_balances)" class="text-positive">
|
{% raw %}{{ formatSats(Math.abs(props.row.balance)) }} sats{% endraw %}
|
||||||
<div v-for="(amount, currency) in owesYouFiat(props.row.fiat_balances)" :key="'oy-' + currency">
|
|
||||||
Owes you {% raw %}{{ formatFiat(amount, currency) }}{% endraw %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- You (org) owe user, per currency -->
|
<div v-if="props.row.fiat_balances && Object.keys(props.row.fiat_balances).length > 0" class="text-caption">
|
||||||
<div v-if="hasYouOweFiat(props.row.fiat_balances)" class="text-negative">
|
<span v-for="(amount, currency) in props.row.fiat_balances" :key="currency" class="q-mr-sm">
|
||||||
<div v-for="(amount, currency) in youOweFiat(props.row.fiat_balances)" :key="'yo-' + currency">
|
{% raw %}{{ formatFiat(Math.abs(amount), currency) }}{% endraw %}
|
||||||
You owe {% raw %}{{ formatFiat(amount, currency) }}{% endraw %}
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Fallback when there are no fiat balances (sats-only entries) -->
|
<div class="text-caption text-grey">
|
||||||
<div v-if="!hasOwesYouFiat(props.row.fiat_balances) && !hasYouOweFiat(props.row.fiat_balances)"
|
{% raw %}{{ props.row.balance > 0 ? 'Owes you' : 'You owe' }}{% endraw %}
|
||||||
: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>
|
</div>
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:body-cell-actions="props">
|
<template v-slot:body-cell-actions="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
<!-- User owes Libra (positive balance) - Libra receives payment -->
|
<!-- User owes Castle (positive balance) - Castle receives payment -->
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="props.row.balance > 0"
|
v-if="props.row.balance > 0"
|
||||||
flat
|
flat
|
||||||
|
|
@ -223,9 +207,9 @@
|
||||||
icon="payments"
|
icon="payments"
|
||||||
@click="showSettleReceivableDialog(props.row)"
|
@click="showSettleReceivableDialog(props.row)"
|
||||||
>
|
>
|
||||||
<q-tooltip>Settle receivable (user pays libra)</q-tooltip>
|
<q-tooltip>Settle receivable (user pays castle)</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<!-- Libra owes User (negative balance) - Libra pays user -->
|
<!-- Castle owes User (negative balance) - Castle pays user -->
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="props.row.balance < 0"
|
v-if="props.row.balance < 0"
|
||||||
flat
|
flat
|
||||||
|
|
@ -235,7 +219,7 @@
|
||||||
icon="send"
|
icon="send"
|
||||||
@click="showPayUserDialog(props.row)"
|
@click="showPayUserDialog(props.row)"
|
||||||
>
|
>
|
||||||
<q-tooltip>Pay user (libra pays user)</q-tooltip>
|
<q-tooltip>Pay user (castle pays user)</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -269,7 +253,7 @@
|
||||||
{% raw %}{{ balance.balance > 0 ? 'Total owed to you' : balance.balance < 0 ? 'Total you owe' : 'No outstanding balances' }}{% endraw %}
|
{% raw %}{{ balance.balance > 0 ? 'Total owed to you' : balance.balance < 0 ? 'Total you owe' : 'No outstanding balances' }}{% endraw %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-subtitle2" v-else>
|
<div class="text-subtitle2" v-else>
|
||||||
{% raw %}{{ balance.balance >= 0 ? 'You owe Libra' : 'Libra owes you' }}{% endraw %}
|
{% raw %}{{ balance.balance >= 0 ? 'You owe Castle' : 'Castle owes you' }}{% endraw %}
|
||||||
</div>
|
</div>
|
||||||
<div class="q-mt-md q-gutter-sm">
|
<div class="q-mt-md q-gutter-sm">
|
||||||
<q-btn
|
<q-btn
|
||||||
|
|
@ -497,10 +481,7 @@
|
||||||
<!-- Status Flag Column -->
|
<!-- Status Flag Column -->
|
||||||
<template v-slot:body-cell-flag="props">
|
<template v-slot:body-cell-flag="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
<q-icon v-if="isVoided(props.row)" name="cancel" color="grey" size="sm">
|
<q-icon v-if="props.row.flag === '*'" name="check_circle" color="positive" 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-tooltip>Cleared</q-tooltip>
|
||||||
</q-icon>
|
</q-icon>
|
||||||
<q-icon v-else-if="props.row.flag === '!'" name="pending" color="orange" size="sm">
|
<q-icon v-else-if="props.row.flag === '!'" name="pending" color="orange" size="sm">
|
||||||
|
|
@ -509,6 +490,9 @@
|
||||||
<q-icon v-else-if="props.row.flag === '#'" name="flag" color="red" size="sm">
|
<q-icon v-else-if="props.row.flag === '#'" name="flag" color="red" size="sm">
|
||||||
<q-tooltip>Flagged</q-tooltip>
|
<q-tooltip>Flagged</q-tooltip>
|
||||||
</q-icon>
|
</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>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -857,20 +841,7 @@
|
||||||
<!-- Chart of Accounts -->
|
<!-- Chart of Accounts -->
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="row items-center q-mb-md">
|
<h6 class="q-my-none q-mb-md">Chart of Accounts</h6>
|
||||||
<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-list dense v-if="accounts.length > 0">
|
||||||
<q-item v-for="account in accounts" :key="account.id">
|
<q-item v-for="account in accounts" :key="account.id">
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
|
|
@ -949,7 +920,7 @@
|
||||||
dense
|
dense
|
||||||
v-model="expenseDialog.isEquity"
|
v-model="expenseDialog.isEquity"
|
||||||
:options="[
|
:options="[
|
||||||
{label: 'Liability (Libra owes me)', value: false},
|
{label: 'Liability (Castle owes me)', value: false},
|
||||||
{label: 'Equity (My contribution)', value: true}
|
{label: 'Equity (My contribution)', value: true}
|
||||||
]"
|
]"
|
||||||
option-label="label"
|
option-label="label"
|
||||||
|
|
@ -957,7 +928,7 @@
|
||||||
emit-value
|
emit-value
|
||||||
map-options
|
map-options
|
||||||
label="Type *"
|
label="Type *"
|
||||||
hint="Choose whether this is a liability (Libra owes you) or an equity contribution"
|
hint="Choose whether this is a liability (Castle owes you) or an equity contribution"
|
||||||
></q-select>
|
></q-select>
|
||||||
|
|
||||||
<!-- If user is not equity eligible, force liability -->
|
<!-- If user is not equity eligible, force liability -->
|
||||||
|
|
@ -966,9 +937,9 @@
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
readonly
|
readonly
|
||||||
:model-value="'Liability (Libra owes me)'"
|
:model-value="'Liability (Castle owes me)'"
|
||||||
label="Type"
|
label="Type"
|
||||||
hint="This expense will be recorded as a liability (Libra owes you)"
|
hint="This expense will be recorded as a liability (Castle owes you)"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-icon name="info" color="blue-grey-7"></q-icon>
|
<q-icon name="info" color="blue-grey-7"></q-icon>
|
||||||
|
|
@ -1081,7 +1052,7 @@
|
||||||
<div class="text-h6 q-mb-md">Request Manual Payment</div>
|
<div class="text-h6 q-mb-md">Request Manual Payment</div>
|
||||||
|
|
||||||
<div class="text-caption text-grey q-mb-md">
|
<div class="text-caption text-grey q-mb-md">
|
||||||
Request a manual payment (cash, bank transfer, etc.) from an admin to settle your balance.
|
Request the Castle to pay you manually (cash, bank transfer, etc.) to settle your balance.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="balance" class="q-mb-md">
|
<div v-if="balance" class="q-mb-md">
|
||||||
|
|
@ -1129,7 +1100,7 @@
|
||||||
<q-dialog v-model="settingsDialog.show" position="top">
|
<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-card v-if="settingsDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
<q-form @submit="submitSettings" class="q-gutter-md">
|
<q-form @submit="submitSettings" class="q-gutter-md">
|
||||||
<div class="text-h6 q-mb-md">Libra Settings</div>
|
<div class="text-h6 q-mb-md">Castle Settings</div>
|
||||||
|
|
||||||
<q-banner v-if="!isSuperUser" class="bg-warning text-dark q-mb-md" dense rounded>
|
<q-banner v-if="!isSuperUser" class="bg-warning text-dark q-mb-md" dense rounded>
|
||||||
<template v-slot:avatar>
|
<template v-slot:avatar>
|
||||||
|
|
@ -1144,53 +1115,17 @@
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
emit-value
|
emit-value
|
||||||
v-model="settingsDialog.libraWalletId"
|
v-model="settingsDialog.castleWalletId"
|
||||||
:options="g.user.walletOptions"
|
:options="g.user.walletOptions"
|
||||||
label="Libra Wallet *"
|
label="Castle Wallet *"
|
||||||
:readonly="!isSuperUser"
|
:readonly="!isSuperUser"
|
||||||
:disable="!isSuperUser"
|
:disable="!isSuperUser"
|
||||||
></q-select>
|
></q-select>
|
||||||
|
|
||||||
<div class="text-caption text-grey q-mb-md">
|
<div class="text-caption text-grey">
|
||||||
Select the wallet that will be used for Libra operations and transactions.
|
Select the wallet that will be used for Castle operations and transactions.
|
||||||
</div>
|
</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">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="isSuperUser"
|
v-if="isSuperUser"
|
||||||
|
|
@ -1198,7 +1133,7 @@
|
||||||
color="primary"
|
color="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
:loading="settingsDialog.loading"
|
:loading="settingsDialog.loading"
|
||||||
:disable="!settingsDialog.libraWalletId"
|
:disable="!settingsDialog.castleWalletId"
|
||||||
>
|
>
|
||||||
Save Settings
|
Save Settings
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
|
@ -1226,7 +1161,7 @@
|
||||||
></q-select>
|
></q-select>
|
||||||
|
|
||||||
<div class="text-caption text-grey">
|
<div class="text-caption text-grey">
|
||||||
Select the wallet you'll use for Libra transactions.
|
Select the wallet you'll use for Castle transactions.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
|
|
@ -1245,63 +1180,6 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</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 -->
|
<!-- Receivable Dialog -->
|
||||||
<q-dialog v-model="receivableDialog.show" position="top">
|
<q-dialog v-model="receivableDialog.show" position="top">
|
||||||
<q-card v-if="receivableDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
<q-card v-if="receivableDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
|
@ -1389,7 +1267,7 @@
|
||||||
<div class="text-caption text-grey q-mb-md">
|
<div class="text-caption text-grey q-mb-md">
|
||||||
Balance assertions are written to your Beancount ledger and validated automatically by Beancount.
|
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.
|
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. Libra stores
|
If the assertion fails, Beancount will alert you to investigate the discrepancy. Castle stores
|
||||||
metadata (tolerance, notes) for your convenience.
|
metadata (tolerance, notes) for your convenience.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1613,7 +1491,7 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
<!-- Pay User Dialog (Libra pays user - Super User Only) -->
|
<!-- Pay User Dialog (Castle pays user - Super User Only) -->
|
||||||
<q-dialog v-model="payUserDialog.show" position="top">
|
<q-dialog v-model="payUserDialog.show" position="top">
|
||||||
<q-card class="q-pa-md" style="min-width: 400px">
|
<q-card class="q-pa-md" style="min-width: 400px">
|
||||||
<q-form @submit="submitPayUser">
|
<q-form @submit="submitPayUser">
|
||||||
|
|
@ -1626,7 +1504,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="q-mb-md">
|
<div class="q-mb-md">
|
||||||
<div class="text-subtitle2">Amount Libra Owes</div>
|
<div class="text-subtitle2">Amount Castle Owes</div>
|
||||||
<div class="text-positive text-h6">
|
<div class="text-positive text-h6">
|
||||||
{% raw %}{{ formatSats(payUserDialog.maxAmount) }}{% endraw %} sats
|
{% raw %}{{ formatSats(payUserDialog.maxAmount) }}{% endraw %} sats
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1641,7 +1519,7 @@
|
||||||
v-model.number="payUserDialog.amount"
|
v-model.number="payUserDialog.amount"
|
||||||
type="number"
|
type="number"
|
||||||
:label="paymentAmountLabel"
|
:label="paymentAmountLabel"
|
||||||
hint="Amount libra is paying (max: owed amount)"
|
hint="Amount castle is paying (max: owed amount)"
|
||||||
:max="paymentMaxAmount"
|
:max="paymentMaxAmount"
|
||||||
:step="paymentAmountStep"
|
:step="paymentAmountStep"
|
||||||
:rules="[
|
:rules="[
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{{ window_vars(user) }}
|
{{ window_vars(user) }}
|
||||||
<script src="{{ static_url_for('libra/static', path='js/permissions.js') }}"></script>
|
<script src="{{ static_url_for('castle/static', path='js/permissions.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block page %}
|
{% block page %}
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,714 +0,0 @@
|
||||||
"""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
428
tests/helpers.py
|
|
@ -1,428 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
"""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"
|
|
||||||
|
|
@ -1,452 +0,0 @@
|
||||||
"""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}"
|
|
||||||
)
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
"""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}"
|
|
||||||
)
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
"""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"]
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
"""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}"
|
|
||||||
|
|
@ -1,307 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -1,294 +0,0 @@
|
||||||
"""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}"
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
"""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}"
|
|
||||||
)
|
|
||||||
|
|
@ -1,342 +0,0 @@
|
||||||
"""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}"
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
"""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, pending→cleared
|
|
||||||
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}"
|
|
||||||
)
|
|
||||||
|
|
@ -1,572 +0,0 @@
|
||||||
"""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"})
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
"""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}"
|
|
||||||
)
|
|
||||||
16
views.py
16
views.py
|
|
@ -4,22 +4,22 @@ from lnbits.core.models import User
|
||||||
from lnbits.decorators import check_user_exists
|
from lnbits.decorators import check_user_exists
|
||||||
from lnbits.helpers import template_renderer
|
from lnbits.helpers import template_renderer
|
||||||
|
|
||||||
libra_generic_router = APIRouter(tags=["libra"])
|
castle_generic_router = APIRouter(tags=["castle"])
|
||||||
|
|
||||||
|
|
||||||
@libra_generic_router.get(
|
@castle_generic_router.get(
|
||||||
"/", description="Libra accounting home page", response_class=HTMLResponse
|
"/", description="Castle accounting home page", response_class=HTMLResponse
|
||||||
)
|
)
|
||||||
async def index(
|
async def index(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists),
|
||||||
):
|
):
|
||||||
return template_renderer(["libra/templates"]).TemplateResponse(
|
return template_renderer(["castle/templates"]).TemplateResponse(
|
||||||
request, "libra/index.html", {"user": user.json()}
|
request, "castle/index.html", {"user": user.json()}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@libra_generic_router.get(
|
@castle_generic_router.get(
|
||||||
"/permissions",
|
"/permissions",
|
||||||
description="Permission management page",
|
description="Permission management page",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
|
|
@ -28,6 +28,6 @@ async def permissions(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists),
|
||||||
):
|
):
|
||||||
return template_renderer(["libra/templates"]).TemplateResponse(
|
return template_renderer(["castle/templates"]).TemplateResponse(
|
||||||
request, "libra/permissions.html", {"user": user.json()}
|
request, "castle/permissions.html", {"user": user.json()}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
1484
views_api.py
1484
views_api.py
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue