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.
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).
-
Challenge: Record the payment while: 1. Clearing the
-exact EUR receivable amount 2. Recording the exact satoshi amount
-received 3. Handling cases where users have both receivables (owe
-Castle) and payables (Castle owes them) 4. Maintaining Beancount
-double-entry balance
Three-Posting Structure: 1. Lightning
-Account: Records SATS received with @@ total price
-notation 2. Receivable Account: Clears EUR receivable
-with sats-equivalent metadata 3. Payable Account:
-Clears any outstanding EUR payables (often 0.00)
-
-
Accounting Issues Identified
-
Issue 1: Zero-Amount Postings
-
Problem: The third posting often records
-0.00 EUR when no payable exists.
-
Liabilities:Payable:User-375ec158 0.00 EUR
-
Why This Is Wrong: - Zero-amount postings have no
-economic substance - Clutters the journal with non-events - Violates the
-principle of materiality (GAAP Concept Statement 2) - Makes auditing
-more difficult (reviewers must verify why zero amounts exist)
-
Accounting Principle Violated: > “Transactions
-should only include postings that represent actual economic events or
-changes in account balances.”
-
Impact: Low severity, but unprofessional
-presentation
Why This Is Problematic: - The @@
-notation already records the exact satoshi amount - Beancount’s price
-database stores this relationship - Metadata becomes redundant for this
-specific posting - Increases storage and potential for inconsistency
-
Technical Detail:
-
The @@ notation means “total price” and Beancount
-converts it to per-unit price:
Don’t: Mix both in the same transaction (current
-implementation)
-
-
Issue 3: No Exchange
-Gain/Loss Recognition
-
Problem: When receivables are denominated in one
-currency (EUR) and paid in another (SATS), exchange rate fluctuations
-create gains or losses that should be recognized.
This hides the exchange variance by treating the
-payment as if it was worth exactly the receivable amount.
-
GAAP/IFRS Requirement:
-
Under both US GAAP (ASC 830) and IFRS (IAS 21), exchange gains and
-losses on monetary items (like receivables) should be recognized in the
-period they occur.
Why This Matters: - Tax reporting may require
-exchange gain/loss recognition - Financial statements misstate true
-economic results - Auditors would flag this as a compliance issue -
-Cannot accurately calculate ROI or performance metrics
-
-
Issue 4: Semantic
-Misuse of Price Notation
-
Problem: The @ notation in Beancount
-represents acquisition cost, not settlement
-value.
What this notation means in accounting: “We
-purchased 225,033 satoshis at a cost of 0.000888 EUR
-per satoshi”
-
What actually happened: “We
-received 225,033 satoshis as payment for a debt”
-
Economic Difference: - Purchase:
-You exchange cash for an asset (buying Bitcoin) - Payment
-Receipt: You receive an asset in settlement of a receivable
-
Accounting Substance vs. Form: -
-Form: The transaction looks like a Bitcoin purchase -
-Substance: The transaction is actually a receivable
-collection
-
GAAP Principle (ASC 105-10-05): > “Accounting
-should reflect the economic substance of transactions, not merely their
-legal form.”
-
Why This Creates Issues:
-
-
Cost Basis Tracking: For tax purposes, the “cost”
-of Bitcoin received as payment should be its fair market value at
-receipt, not the receivable amount
-
Price Database Pollution: Beancount’s price
-database now contains “prices” that aren’t real market prices
-
Auditor Confusion: An auditor reviewing this would
-question why purchase prices don’t match market rates
-
-
Proper Accounting Approach:
-
; Approach 1: Record at fair market value
-Assets:Bitcoin:Lightning 225033 SATS @ 0.000886... EUR
- ; Using actual market price at time of receipt
- acquisition-type: "payment-received"
-Revenue:Exchange-Gain 0.50 EUR
-Assets:Receivable:User-375ec158 -200.00 EUR
-
-; Approach 2: Don't use @ notation at all
-Assets:Bitcoin:Lightning 200.00 EUR
- sats-received: "225033"
- fmv-at-receipt: "199.50 EUR"
-Assets:Receivable:User-375ec158 -200.00 EUR
-
-
Issue 5: Misnamed
-Function and Incorrect Usage
-
Problem: Function is called
-format_net_settlement_entry, but it’s used for simple
-payments that aren’t true net settlements.
-
Example from User’s Transaction: - Receivable:
-200.00 EUR - Payable: 0.00 EUR - Net: 200.00 EUR (this is just a
-payment, not a settlement)
-
Accounting Terminology:
-
-
Payment: Settling a single obligation (receivable
-OR payable)
-
Net Settlement: Offsetting multiple obligations
-(receivable AND payable)
Best Practice: Use the simplest journal entry
-structure that accurately represents the transaction.
-
Recommendation: 1. Rename function to
-format_payment_entry or
-format_receivable_payment_entry 2. Create separate
-format_net_settlement_entry for true netting scenarios 3.
-Use conditional logic to choose 2-posting vs 3-posting based on whether
-both receivables AND payables exist
-
-
Traditional Accounting
-Approaches
-
Approach
-1: Record Bitcoin at Fair Market Value (Tax Compliant)
Current Issue: Mixed approach (EUR positions with
-SATS metadata, but also SATS positions with @ notation)
-
Decision Required: Choose ONE of the following
-architectures:
-
Architecture A - EUR Primary, SATS Secondary
-(recommended):
-
; All positions in EUR, SATS in metadata
-2025-11-12 * "Payment"
- Assets:Bitcoin:Lightning 200.00 EUR
- sats-received: "225033"
- Assets:Receivable:User -200.00 EUR
- sats-cleared: "225033"
-
Architecture B - SATS Primary, EUR Secondary:
-
; All positions in SATS, EUR in metadata
-2025-11-12 * "Payment"
- Assets:Bitcoin:Lightning 225033 SATS
- eur-value: "200.00"
- Assets:Receivable:User -225033 SATS
- eur-cleared: "200.00"
-
Recommendation: Architecture A (EUR primary)
-because: 1. Most receivables created in EUR 2. Financial reporting
-requirements typically in fiat 3. Tax obligations calculated in fiat 4.
-Aligns with current Castle metadata approach
-
-
3.2
-Consider Separate Ledger for Cryptocurrency Holdings
-
Advanced Approach: Separate cryptocurrency movements
-from fiat accounting
-
Main Ledger (EUR-denominated):
-
2025-11-12 * "Payment received from user"
- Assets:Bitcoin-Custody:User-375ec158 200.00 EUR
- Assets:Receivable:User-375ec158 -200.00 EUR
Verify: - ✅ Bitcoin recorded at fair market value -
-✅ Exchange loss recognized - ✅ Receivable cleared at book value - ✅
-Entry balances
-
-
Conclusion
-
Summary of Issues
-
-
-
-
-
-
-
-
-
-
Issue
-
Severity
-
Accounting Impact
-
Recommended Action
-
-
-
-
-
Zero-amount postings
-
Low
-
Presentation only
-
Remove immediately
-
-
-
Redundant SATS tracking
-
Low
-
Storage/efficiency
-
Choose one method
-
-
-
No exchange gain/loss
-
High
-
Financial accuracy
-
Implement for compliance
-
-
-
Semantic misuse of @
-
Medium
-
Audit clarity
-
Consider EUR-only positions
-
-
-
Misnamed function
-
Low
-
Code clarity
-
Rename function
-
-
-
-
Professional Assessment
-
Is this “best practice” accounting?
-No, this implementation deviates from traditional
-accounting standards in several ways.
-
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).
-
Critical improvements needed: 1. ✅ Remove
-zero-amount postings (easy fix, professional presentation) 2. ✅
-Implement exchange gain/loss tracking (required for compliance) 3. ✅
-Separate payment vs. settlement logic (accuracy and clarity)
-
The fundamental challenge: Traditional accounting
-wasn’t designed for this scenario. There is no established “standard”
-for recording cryptocurrency payments of fiat-denominated receivables.
-Castle’s approach is functional, but should be refined to align better
-with accounting principles where possible.
Document Version: 1.0 Last Updated:
-2025-01-12 Next Review: After Priority 1 fixes
-implemented
-
-
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.
-
-
diff --git a/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.md b/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.md
deleted file mode 100644
index b145128..0000000
--- a/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.md
+++ /dev/null
@@ -1,861 +0,0 @@
-# Accounting Analysis: Net Settlement Entry Pattern
-
-**Date**: 2025-01-12
-**Prepared By**: Senior Accounting Review
-**Subject**: Castle Extension - Lightning Payment Settlement Entries
-**Status**: Technical Review
-
----
-
-## Executive Summary
-
-This document provides a professional accounting assessment of Castle's net settlement entry pattern used for recording Lightning Network payments that settle fiat-denominated receivables. The analysis identifies areas where the implementation deviates from traditional accounting best practices and provides specific recommendations for improvement.
-
-**Key Findings**:
-- ✅ Double-entry integrity maintained
-- ✅ Functional for intended purpose
-- ❌ Zero-amount postings violate accounting principles
-- ❌ Redundant satoshi tracking
-- ❌ No exchange gain/loss recognition
-- ⚠️ Mixed currency approach lacks clear hierarchy
-
----
-
-## Background: The Technical Challenge
-
-Castle operates as a Lightning Network-integrated accounting system for collectives (co-living spaces, makerspaces). It faces a unique accounting challenge:
-
-**Scenario**: User creates a receivable in EUR (e.g., €200 for room rent), then pays via Lightning Network in satoshis (225,033 sats).
-
-**Challenge**: Record the payment while:
-1. Clearing the exact EUR receivable amount
-2. Recording the exact satoshi amount received
-3. Handling cases where users have both receivables (owe Castle) and payables (Castle owes them)
-4. Maintaining Beancount double-entry balance
-
----
-
-## Current Implementation
-
-### Transaction Example
-
-```beancount
-; Step 1: Receivable Created
-2025-11-12 * "room (200.00 EUR)" #receivable-entry
- user-id: "375ec158"
- source: "castle-api"
- sats-amount: "225033"
- Assets:Receivable:User-375ec158 200.00 EUR
- sats-equivalent: "225033"
- Income:Accommodation:Guests -200.00 EUR
- sats-equivalent: "225033"
-
-; Step 2: Lightning Payment Received
-2025-11-12 * "Lightning payment settlement from user 375ec158"
- #lightning-payment #net-settlement
- user-id: "375ec158"
- source: "lightning_payment"
- payment-type: "net-settlement"
- payment-hash: "8d080ec4cc4301715535004156085dd50c159185..."
- Assets:Bitcoin:Lightning 225033 SATS @ 0.0008887585... EUR
- payment-hash: "8d080ec4cc4301715535004156085dd50c159185..."
- Assets:Receivable:User-375ec158 -200.00 EUR
- sats-equivalent: "225033"
- Liabilities:Payable:User-375ec158 0.00 EUR
-```
-
-### Code Implementation
-
-**Location**: `beancount_format.py:739-760`
-
-```python
-# Build postings for net settlement
-postings = [
- {
- "account": payment_account,
- "amount": f"{abs(amount_sats)} SATS @@ {abs(net_fiat_amount):.2f} {fiat_currency}",
- "meta": {"payment-hash": payment_hash} if payment_hash else {}
- },
- {
- "account": receivable_account,
- "amount": f"-{abs(total_receivable_fiat):.2f} {fiat_currency}",
- "meta": {"sats-equivalent": str(abs(amount_sats))}
- },
- {
- "account": payable_account,
- "amount": f"{abs(total_payable_fiat):.2f} {fiat_currency}",
- "meta": {}
- }
-]
-```
-
-**Three-Posting Structure**:
-1. **Lightning Account**: Records SATS received with `@@` total price notation
-2. **Receivable Account**: Clears EUR receivable with sats-equivalent metadata
-3. **Payable Account**: Clears any outstanding EUR payables (often 0.00)
-
----
-
-## Accounting Issues Identified
-
-### Issue 1: Zero-Amount Postings
-
-**Problem**: The third posting often records `0.00 EUR` when no payable exists.
-
-```beancount
-Liabilities:Payable:User-375ec158 0.00 EUR
-```
-
-**Why This Is Wrong**:
-- Zero-amount postings have no economic substance
-- Clutters the journal with non-events
-- Violates the principle of materiality (GAAP Concept Statement 2)
-- Makes auditing more difficult (reviewers must verify why zero amounts exist)
-
-**Accounting Principle Violated**:
-> "Transactions should only include postings that represent actual economic events or changes in account balances."
-
-**Impact**: Low severity, but unprofessional presentation
-
-**Recommendation**:
-```python
-# Make payable posting conditional
-postings = [
- {"account": payment_account, "amount": ...},
- {"account": receivable_account, "amount": ...}
-]
-
-# Only add payable posting if there's actually a payable
-if total_payable_fiat > 0:
- postings.append({
- "account": payable_account,
- "amount": f"{abs(total_payable_fiat):.2f} {fiat_currency}",
- "meta": {}
- })
-```
-
----
-
-### Issue 2: Redundant Satoshi Tracking
-
-**Problem**: Satoshis are tracked in TWO places in the same transaction:
-
-1. **Position Amount** (via `@@` notation):
- ```beancount
- Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR
- ```
-
-2. **Metadata** (sats-equivalent):
- ```beancount
- Assets:Receivable:User-375ec158 -200.00 EUR
- sats-equivalent: "225033"
- ```
-
-**Why This Is Problematic**:
-- The `@@` notation already records the exact satoshi amount
-- Beancount's price database stores this relationship
-- Metadata becomes redundant for this specific posting
-- Increases storage and potential for inconsistency
-
-**Technical Detail**:
-
-The `@@` notation means "total price" and Beancount converts it to per-unit price:
-```beancount
-; You write:
-Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR
-
-; Beancount stores:
-Assets:Bitcoin:Lightning 225033 SATS @ 0.0008887585... EUR
-; (where 200.00 / 225033 = 0.0008887585...)
-```
-
-Beancount can query this:
-```sql
-SELECT account, sum(convert(position, SATS))
-WHERE account = 'Assets:Bitcoin:Lightning'
-```
-
-**Recommendation**:
-
-Choose ONE approach consistently:
-
-**Option A - Use @ notation** (Beancount standard):
-```beancount
-Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR
- payment-hash: "8d080ec4..."
-Assets:Receivable:User-375ec158 -200.00 EUR
- ; No sats-equivalent needed here
-```
-
-**Option B - Use EUR positions with metadata** (Castle's current approach):
-```beancount
-Assets:Bitcoin:Lightning 200.00 EUR
- sats-received: "225033"
- payment-hash: "8d080ec4..."
-Assets:Receivable:User-375ec158 -200.00 EUR
- sats-cleared: "225033"
-```
-
-**Don't**: Mix both in the same transaction (current implementation)
-
----
-
-### Issue 3: No Exchange Gain/Loss Recognition
-
-**Problem**: When receivables are denominated in one currency (EUR) and paid in another (SATS), exchange rate fluctuations create gains or losses that should be recognized.
-
-**Example Scenario**:
-
-```
-Day 1 - Receivable Created:
- 200 EUR = 225,033 SATS (rate: 1,125.165 sats/EUR)
-
-Day 5 - Payment Received:
- 225,033 SATS = 199.50 EUR (rate: 1,127.682 sats/EUR)
- Exchange rate moved unfavorably
-
-Economic Reality: 0.50 EUR LOSS
-```
-
-**Current Implementation**: Forces balance by calculating the `@` rate to make it exactly 200 EUR:
-```beancount
-Assets:Bitcoin:Lightning 225033 SATS @ 0.000888... EUR ; = exactly 200.00 EUR
-```
-
-This **hides the exchange variance** by treating the payment as if it was worth exactly the receivable amount.
-
-**GAAP/IFRS Requirement**:
-
-Under both US GAAP (ASC 830) and IFRS (IAS 21), exchange gains and losses on monetary items (like receivables) should be recognized in the period they occur.
-
-**Proper Accounting Treatment**:
-
-```beancount
-2025-11-12 * "Lightning payment with exchange loss"
- Assets:Bitcoin:Lightning 225033 SATS @ 0.000886... EUR
- ; Market rate at payment time = 199.50 EUR
- Expenses:Foreign-Exchange-Loss 0.50 EUR
- Assets:Receivable:User-375ec158 -200.00 EUR
-```
-
-**Impact**: Moderate severity - affects financial statement accuracy
-
-**Why This Matters**:
-- Tax reporting may require exchange gain/loss recognition
-- Financial statements misstate true economic results
-- Auditors would flag this as a compliance issue
-- Cannot accurately calculate ROI or performance metrics
-
----
-
-### Issue 4: Semantic Misuse of Price Notation
-
-**Problem**: The `@` notation in Beancount represents **acquisition cost**, not **settlement value**.
-
-**Current Usage**:
-```beancount
-Assets:Bitcoin:Lightning 225033 SATS @ 0.000888... EUR
-```
-
-**What this notation means in accounting**: "We **purchased** 225,033 satoshis at a cost of 0.000888 EUR per satoshi"
-
-**What actually happened**: "We **received** 225,033 satoshis as payment for a debt"
-
-**Economic Difference**:
-- **Purchase**: You exchange cash for an asset (buying Bitcoin)
-- **Payment Receipt**: You receive an asset in settlement of a receivable
-
-**Accounting Substance vs. Form**:
-- **Form**: The transaction looks like a Bitcoin purchase
-- **Substance**: The transaction is actually a receivable collection
-
-**GAAP Principle (ASC 105-10-05)**:
-> "Accounting should reflect the economic substance of transactions, not merely their legal form."
-
-**Why This Creates Issues**:
-
-1. **Cost Basis Tracking**: For tax purposes, the "cost" of Bitcoin received as payment should be its fair market value at receipt, not the receivable amount
-2. **Price Database Pollution**: Beancount's price database now contains "prices" that aren't real market prices
-3. **Auditor Confusion**: An auditor reviewing this would question why purchase prices don't match market rates
-
-**Proper Accounting Approach**:
-
-```beancount
-; Approach 1: Record at fair market value
-Assets:Bitcoin:Lightning 225033 SATS @ 0.000886... EUR
- ; Using actual market price at time of receipt
- acquisition-type: "payment-received"
-Revenue:Exchange-Gain 0.50 EUR
-Assets:Receivable:User-375ec158 -200.00 EUR
-
-; Approach 2: Don't use @ notation at all
-Assets:Bitcoin:Lightning 200.00 EUR
- sats-received: "225033"
- fmv-at-receipt: "199.50 EUR"
-Assets:Receivable:User-375ec158 -200.00 EUR
-```
-
----
-
-### Issue 5: Misnamed Function and Incorrect Usage
-
-**Problem**: Function is called `format_net_settlement_entry`, but it's used for simple payments that aren't true net settlements.
-
-**Example from User's Transaction**:
-- Receivable: 200.00 EUR
-- Payable: 0.00 EUR
-- Net: 200.00 EUR (this is just a **payment**, not a **settlement**)
-
-**Accounting Terminology**:
-
-- **Payment**: Settling a single obligation (receivable OR payable)
-- **Net Settlement**: Offsetting multiple obligations (receivable AND payable)
-
-**When Net Settlement is Appropriate**:
-
-```
-User owes Castle: 555.00 EUR (receivable)
-Castle owes User: 38.00 EUR (payable)
-Net amount due: 517.00 EUR (true settlement)
-```
-
-Proper three-posting entry:
-```beancount
-Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR
-Assets:Receivable:User -555.00 EUR
-Liabilities:Payable:User 38.00 EUR
-; Net: 517.00 = -555.00 + 38.00 ✓
-```
-
-**When Two Postings Suffice**:
-
-```
-User owes Castle: 200.00 EUR (receivable)
-Castle owes User: 0.00 EUR (no payable)
-Amount due: 200.00 EUR (simple payment)
-```
-
-Simpler two-posting entry:
-```beancount
-Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR
-Assets:Receivable:User -200.00 EUR
-```
-
-**Best Practice**: Use the simplest journal entry structure that accurately represents the transaction.
-
-**Recommendation**:
-1. Rename function to `format_payment_entry` or `format_receivable_payment_entry`
-2. Create separate `format_net_settlement_entry` for true netting scenarios
-3. Use conditional logic to choose 2-posting vs 3-posting based on whether both receivables AND payables exist
-
----
-
-## Traditional Accounting Approaches
-
-### Approach 1: Record Bitcoin at Fair Market Value (Tax Compliant)
-
-```beancount
-2025-11-12 * "Bitcoin payment from user 375ec158"
- Assets:Bitcoin:Lightning 199.50 EUR
- sats-received: "225033"
- fmv-per-sat: "0.000886 EUR"
- cost-basis: "199.50 EUR"
- payment-hash: "8d080ec4..."
- Revenue:Exchange-Gain 0.50 EUR
- source: "cryptocurrency-receipt"
- Assets:Receivable:User-375ec158 -200.00 EUR
-```
-
-**Pros**:
-- ✅ Tax compliant (establishes cost basis)
-- ✅ Recognizes exchange gain/loss
-- ✅ Uses actual market rates
-- ✅ Audit trail for cryptocurrency receipts
-
-**Cons**:
-- ❌ Requires real-time price feeds
-- ❌ Creates taxable events
-
----
-
-### Approach 2: Simplified EUR-Only Ledger (No SATS Positions)
-
-```beancount
-2025-11-12 * "Bitcoin payment from user 375ec158"
- Assets:Bitcoin:Lightning 200.00 EUR
- sats-received: "225033"
- sats-rate: "1125.165"
- payment-hash: "8d080ec4..."
- Assets:Receivable:User-375ec158 -200.00 EUR
-```
-
-**Pros**:
-- ✅ Simple and clean
-- ✅ EUR positions match accounting reality
-- ✅ SATS tracked in metadata for reference
-- ✅ No artificial price notation
-
-**Cons**:
-- ❌ SATS not queryable via Beancount positions
-- ❌ Requires metadata parsing for SATS balances
-
----
-
-### Approach 3: True Net Settlement (When Both Obligations Exist)
-
-```beancount
-2025-11-12 * "Net settlement via Lightning"
- ; User owes 555 EUR, Castle owes 38 EUR, net: 517 EUR
- Assets:Bitcoin:Lightning 517.00 EUR
- sats-received: "565251"
- Assets:Receivable:User-375ec158 -555.00 EUR
- Liabilities:Payable:User-375ec158 38.00 EUR
-```
-
-**When to Use**: Only when **both** receivables and payables exist and you're truly netting them.
-
----
-
-## Recommendations
-
-### Priority 1: Immediate Fixes (Easy Wins)
-
-#### 1.1 Remove Zero-Amount Postings
-
-**File**: `beancount_format.py:739-760`
-
-**Current Code**:
-```python
-postings = [
- {...}, # Lightning
- {...}, # Receivable
- { # Payable (always included, even if 0.00)
- "account": payable_account,
- "amount": f"{abs(total_payable_fiat):.2f} {fiat_currency}",
- "meta": {}
- }
-]
-```
-
-**Fixed Code**:
-```python
-postings = [
- {
- "account": payment_account,
- "amount": f"{abs(amount_sats)} SATS @@ {abs(net_fiat_amount):.2f} {fiat_currency}",
- "meta": {"payment-hash": payment_hash} if payment_hash else {}
- },
- {
- "account": receivable_account,
- "amount": f"-{abs(total_receivable_fiat):.2f} {fiat_currency}",
- "meta": {"sats-equivalent": str(abs(amount_sats))}
- }
-]
-
-# Only add payable posting if there's actually a payable to clear
-if total_payable_fiat > 0:
- postings.append({
- "account": payable_account,
- "amount": f"{abs(total_payable_fiat):.2f} {fiat_currency}",
- "meta": {}
- })
-```
-
-**Impact**: Cleaner journal, professional presentation, easier auditing
-
----
-
-#### 1.2 Choose One SATS Tracking Method
-
-**Decision Required**: Select either position-based OR metadata-based satoshi tracking.
-
-**Option A - Keep Metadata Approach** (recommended for Castle):
-```python
-# In format_net_settlement_entry()
-postings = [
- {
- "account": payment_account,
- "amount": f"{abs(net_fiat_amount):.2f} {fiat_currency}", # EUR only
- "meta": {
- "sats-received": str(abs(amount_sats)),
- "payment-hash": payment_hash
- }
- },
- {
- "account": receivable_account,
- "amount": f"-{abs(total_receivable_fiat):.2f} {fiat_currency}",
- "meta": {"sats-cleared": str(abs(amount_sats))}
- }
-]
-```
-
-**Option B - Use Position-Based Tracking**:
-```python
-# Remove sats-equivalent metadata entirely
-postings = [
- {
- "account": payment_account,
- "amount": f"{abs(amount_sats)} SATS @@ {abs(net_fiat_amount):.2f} {fiat_currency}",
- "meta": {"payment-hash": payment_hash}
- },
- {
- "account": receivable_account,
- "amount": f"-{abs(total_receivable_fiat):.2f} {fiat_currency}",
- # No sats-equivalent needed - queryable via price database
- }
-]
-```
-
-**Recommendation**: Choose Option A (metadata) for consistency with Castle's architecture.
-
----
-
-#### 1.3 Rename Function for Clarity
-
-**File**: `beancount_format.py`
-
-**Current**: `format_net_settlement_entry()`
-
-**New**: `format_receivable_payment_entry()` or `format_payment_settlement_entry()`
-
-**Rationale**: More accurately describes what the function does (processes payments, not always net settlements)
-
----
-
-### Priority 2: Medium-Term Improvements (Compliance)
-
-#### 2.1 Add Exchange Gain/Loss Tracking
-
-**File**: `tasks.py:259-276` (get balance and calculate settlement)
-
-**New Logic**:
-```python
-# Get user's current balance
-balance = await fava.get_user_balance(user_id)
-fiat_balances = balance.get("fiat_balances", {})
-total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0))
-
-# Calculate expected fiat value of SATS payment at current market rate
-market_rate = await get_current_sats_eur_rate() # New function needed
-market_value = Decimal(amount_sats) * market_rate
-
-# Calculate exchange variance
-receivable_amount = abs(total_fiat_balance) if total_fiat_balance > 0 else Decimal(0)
-exchange_variance = market_value - receivable_amount
-
-# If variance is material (> 1 cent), create exchange gain/loss posting
-if abs(exchange_variance) > Decimal("0.01"):
- # Add exchange gain/loss to postings
- if exchange_variance > 0:
- # Gain: payment worth more than receivable
- exchange_account = "Revenue:Foreign-Exchange-Gain"
- else:
- # Loss: payment worth less than receivable
- exchange_account = "Expenses:Foreign-Exchange-Loss"
-
- # Include in entry creation
- exchange_posting = {
- "account": exchange_account,
- "amount": f"{abs(exchange_variance):.2f} {fiat_currency}",
- "meta": {
- "sats-amount": str(amount_sats),
- "market-rate": str(market_rate),
- "receivable-amount": str(receivable_amount)
- }
- }
-```
-
-**Benefits**:
-- ✅ Tax compliance
-- ✅ Accurate financial reporting
-- ✅ Audit trail for cryptocurrency gains/losses
-- ✅ Regulatory compliance (GAAP/IFRS)
-
----
-
-#### 2.2 Implement True Net Settlement vs. Simple Payment Logic
-
-**File**: `tasks.py` or new `payment_logic.py`
-
-```python
-async def create_payment_entry(
- user_id: str,
- amount_sats: int,
- fiat_amount: Decimal,
- fiat_currency: str,
- payment_hash: str
-):
- """
- Create appropriate payment entry based on user's balance situation.
- Uses 2-posting for simple payments, 3-posting for net settlements.
- """
- # Get user balance
- balance = await fava.get_user_balance(user_id)
- fiat_balances = balance.get("fiat_balances", {})
- total_balance = fiat_balances.get(fiat_currency, Decimal(0))
-
- receivable_amount = Decimal(0)
- payable_amount = Decimal(0)
-
- if total_balance > 0:
- receivable_amount = total_balance
- elif total_balance < 0:
- payable_amount = abs(total_balance)
-
- # Determine entry type
- if receivable_amount > 0 and payable_amount > 0:
- # TRUE NET SETTLEMENT: Both obligations exist
- return await format_net_settlement_entry(
- user_id=user_id,
- amount_sats=amount_sats,
- receivable_amount=receivable_amount,
- payable_amount=payable_amount,
- fiat_amount=fiat_amount,
- fiat_currency=fiat_currency,
- payment_hash=payment_hash
- )
- elif receivable_amount > 0:
- # SIMPLE RECEIVABLE PAYMENT: Only receivable exists
- return await format_receivable_payment_entry(
- user_id=user_id,
- amount_sats=amount_sats,
- receivable_amount=receivable_amount,
- fiat_amount=fiat_amount,
- fiat_currency=fiat_currency,
- payment_hash=payment_hash
- )
- else:
- # PAYABLE PAYMENT: Castle paying user (different flow)
- return await format_payable_payment_entry(...)
-```
-
----
-
-### Priority 3: Long-Term Architectural Decisions
-
-#### 3.1 Establish Primary Currency Hierarchy
-
-**Current Issue**: Mixed approach (EUR positions with SATS metadata, but also SATS positions with @ notation)
-
-**Decision Required**: Choose ONE of the following architectures:
-
-**Architecture A - EUR Primary, SATS Secondary** (recommended):
-```beancount
-; All positions in EUR, SATS in metadata
-2025-11-12 * "Payment"
- Assets:Bitcoin:Lightning 200.00 EUR
- sats-received: "225033"
- Assets:Receivable:User -200.00 EUR
- sats-cleared: "225033"
-```
-
-**Architecture B - SATS Primary, EUR Secondary**:
-```beancount
-; All positions in SATS, EUR in metadata
-2025-11-12 * "Payment"
- Assets:Bitcoin:Lightning 225033 SATS
- eur-value: "200.00"
- Assets:Receivable:User -225033 SATS
- eur-cleared: "200.00"
-```
-
-**Recommendation**: Architecture A (EUR primary) because:
-1. Most receivables created in EUR
-2. Financial reporting requirements typically in fiat
-3. Tax obligations calculated in fiat
-4. Aligns with current Castle metadata approach
-
----
-
-#### 3.2 Consider Separate Ledger for Cryptocurrency Holdings
-
-**Advanced Approach**: Separate cryptocurrency movements from fiat accounting
-
-**Main Ledger** (EUR-denominated):
-```beancount
-2025-11-12 * "Payment received from user"
- Assets:Bitcoin-Custody:User-375ec158 200.00 EUR
- Assets:Receivable:User-375ec158 -200.00 EUR
-```
-
-**Cryptocurrency Sub-Ledger** (SATS-denominated):
-```beancount
-2025-11-12 * "Lightning payment received"
- Assets:Bitcoin:Lightning:Castle 225033 SATS
- Assets:Bitcoin:Custody:User-375ec 225033 SATS
-```
-
-**Benefits**:
-- ✅ Clean separation of concerns
-- ✅ Cryptocurrency movements tracked independently
-- ✅ Fiat accounting unaffected by Bitcoin volatility
-- ✅ Can generate separate financial statements
-
-**Drawbacks**:
-- ❌ Increased complexity
-- ❌ Reconciliation between ledgers required
-- ❌ Two sets of books to maintain
-
----
-
-## Code Files Requiring Changes
-
-### High Priority (Immediate Fixes)
-
-1. **`beancount_format.py:739-760`**
- - Remove zero-amount postings
- - Make payable posting conditional
-
-2. **`beancount_format.py:692`**
- - Rename function to `format_receivable_payment_entry`
-
-### Medium Priority (Compliance)
-
-3. **`tasks.py:235-310`**
- - Add exchange gain/loss calculation
- - Implement payment vs. settlement logic
-
-4. **New file: `exchange_rates.py`**
- - Create `get_current_sats_eur_rate()` function
- - Implement price feed integration
-
-5. **`beancount_format.py`**
- - Create new `format_net_settlement_entry()` for true netting
- - Create `format_receivable_payment_entry()` for simple payments
-
----
-
-## Testing Requirements
-
-### Test Case 1: Simple Receivable Payment (No Payable)
-
-**Setup**:
-- User has receivable: 200.00 EUR
-- User has payable: 0.00 EUR
-- User pays: 225,033 SATS
-
-**Expected Entry** (after fixes):
-```beancount
-2025-11-12 * "Lightning payment from user"
- Assets:Bitcoin:Lightning 200.00 EUR
- sats-received: "225033"
- payment-hash: "8d080ec4..."
- Assets:Receivable:User -200.00 EUR
- sats-cleared: "225033"
-```
-
-**Verify**:
-- ✅ Only 2 postings (no zero-amount payable)
-- ✅ Entry balances
-- ✅ SATS tracked in metadata
-- ✅ User balance becomes 0 (both EUR and SATS)
-
----
-
-### Test Case 2: True Net Settlement
-
-**Setup**:
-- User has receivable: 555.00 EUR
-- User has payable: 38.00 EUR
-- Net owed: 517.00 EUR
-- User pays: 565,251 SATS (worth 517.00 EUR)
-
-**Expected Entry**:
-```beancount
-2025-11-12 * "Net settlement via Lightning"
- Assets:Bitcoin:Lightning 517.00 EUR
- sats-received: "565251"
- payment-hash: "abc123..."
- Assets:Receivable:User -555.00 EUR
- sats-portion: "565251"
- Liabilities:Payable:User 38.00 EUR
-```
-
-**Verify**:
-- ✅ 3 postings (receivable + payable cleared)
-- ✅ Net amount = receivable - payable
-- ✅ Both balances become 0
-- ✅ Mathematically balanced
-
----
-
-### Test Case 3: Exchange Gain/Loss (Future)
-
-**Setup**:
-- User has receivable: 200.00 EUR (created at 1,125 sats/EUR)
-- User pays: 225,033 SATS (now worth 199.50 EUR at market)
-- Exchange loss: 0.50 EUR
-
-**Expected Entry** (with exchange tracking):
-```beancount
-2025-11-12 * "Lightning payment with exchange loss"
- Assets:Bitcoin:Lightning 199.50 EUR
- sats-received: "225033"
- market-rate: "0.000886"
- Expenses:Foreign-Exchange-Loss 0.50 EUR
- Assets:Receivable:User -200.00 EUR
-```
-
-**Verify**:
-- ✅ Bitcoin recorded at fair market value
-- ✅ Exchange loss recognized
-- ✅ Receivable cleared at book value
-- ✅ Entry balances
-
----
-
-## Conclusion
-
-### Summary of Issues
-
-| Issue | Severity | Accounting Impact | Recommended Action |
-|-------|----------|-------------------|-------------------|
-| Zero-amount postings | Low | Presentation only | Remove immediately |
-| Redundant SATS tracking | Low | Storage/efficiency | Choose one method |
-| No exchange gain/loss | **High** | Financial accuracy | Implement for compliance |
-| Semantic misuse of @ | Medium | Audit clarity | Consider EUR-only positions |
-| Misnamed function | Low | Code clarity | Rename function |
-
-### Professional Assessment
-
-**Is this "best practice" accounting?**
-**No**, this implementation deviates from traditional accounting standards in several ways.
-
-**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).
-
-**Critical improvements needed**:
-1. ✅ Remove zero-amount postings (easy fix, professional presentation)
-2. ✅ Implement exchange gain/loss tracking (required for compliance)
-3. ✅ Separate payment vs. settlement logic (accuracy and clarity)
-
-**The fundamental challenge**: Traditional accounting wasn't designed for this scenario. There is no established "standard" for recording cryptocurrency payments of fiat-denominated receivables. Castle's approach is functional, but should be refined to align better with accounting principles where possible.
-
-### Next Steps
-
-1. **Week 1**: Implement Priority 1 fixes (remove zero postings, rename function)
-2. **Week 2-3**: Design and implement exchange gain/loss tracking
-3. **Week 4**: Add payment vs. settlement logic
-4. **Ongoing**: Monitor regulatory guidance on cryptocurrency accounting
-
----
-
-## References
-
-- **FASB ASC 830**: Foreign Currency Matters
-- **IAS 21**: The Effects of Changes in Foreign Exchange Rates
-- **FASB Concept Statement No. 2**: Qualitative Characteristics of Accounting Information
-- **ASC 105-10-05**: Substance Over Form
-- **Beancount Documentation**: http://furius.ca/beancount/doc/index
-- **Castle Extension**: `docs/SATS-EQUIVALENT-METADATA.md`
-- **BQL Analysis**: `docs/BQL-BALANCE-QUERIES.md`
-
----
-
-**Document Version**: 1.0
-**Last Updated**: 2025-01-12
-**Next Review**: After Priority 1 fixes implemented
-
----
-
-*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.*
diff --git a/docs/BEANCOUNT_PATTERNS.md b/docs/BEANCOUNT_PATTERNS.md
index 2124c92..907ebc6 100644
--- a/docs/BEANCOUNT_PATTERNS.md
+++ b/docs/BEANCOUNT_PATTERNS.md
@@ -61,7 +61,8 @@ class ImmutableEntryLine(NamedTuple):
id: str
journal_entry_id: str
account_id: str
- amount: int # Beancount-style: positive = debit, negative = credit
+ debit: int
+ credit: int
description: Optional[str]
metadata: dict[str, Any]
flag: Optional[str] # Like Beancount: '!', '*', etc.
@@ -144,14 +145,15 @@ class CastlePlugin(Protocol):
__plugins__ = ('check_all_balanced',)
def check_all_balanced(entries, settings, config):
- """Verify all journal entries balance (sum of amounts = 0)"""
+ """Verify all journal entries have debits = credits"""
errors = []
for entry in entries:
- total_amount = sum(line.amount for line in entry.lines)
- if total_amount != 0:
+ total_debits = sum(line.debit for line in entry.lines)
+ total_credits = sum(line.credit for line in entry.lines)
+ if total_debits != total_credits:
errors.append({
'entry_id': entry.id,
- 'message': f'Unbalanced entry: sum of amounts={total_amount} (must equal 0)',
+ 'message': f'Unbalanced entry: debits={total_debits}, credits={total_credits}',
'severity': 'error'
})
return entries, errors
@@ -182,7 +184,7 @@ def check_receivable_limits(entries, settings, config):
for line in entry.lines:
if 'Accounts Receivable' in line.account_name:
user_id = extract_user_from_account(line.account_name)
- receivables[user_id] = receivables.get(user_id, 0) + line.amount
+ receivables[user_id] = receivables.get(user_id, 0) + line.debit - line.credit
for user_id, amount in receivables.items():
if amount > max_per_user:
@@ -365,15 +367,22 @@ async def get_user_inventory(user_id: str) -> CastleInventory:
# Add as position
metadata = json.loads(line.metadata) if line.metadata else {}
- if line.amount != 0:
- # Beancount-style: positive = debit, negative = credit
- # Adjust sign for cost amount based on amount direction
- cost_sign = 1 if line.amount > 0 else -1
+ if line.debit > 0:
inventory.add_position(CastlePosition(
currency="SATS",
- amount=Decimal(line.amount),
+ amount=Decimal(line.debit),
cost_currency=metadata.get("fiat_currency"),
- cost_amount=cost_sign * Decimal(metadata.get("fiat_amount", 0)),
+ cost_amount=Decimal(metadata.get("fiat_amount", 0)),
+ date=line.created_at,
+ metadata=metadata
+ ))
+
+ if line.credit > 0:
+ inventory.add_position(CastlePosition(
+ currency="SATS",
+ amount=-Decimal(line.credit),
+ cost_currency=metadata.get("fiat_currency"),
+ cost_amount=-Decimal(metadata.get("fiat_amount", 0)),
date=line.created_at,
metadata=metadata
))
@@ -831,16 +840,17 @@ class UnbalancedEntryError(NamedTuple):
async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]:
errors = []
- # Beancount-style: sum of amounts must equal 0
- total_amount = sum(line.amount for line in entry.lines)
+ total_debits = sum(line.debit for line in entry.lines)
+ total_credits = sum(line.credit for line in entry.lines)
- if total_amount != 0:
+ if total_debits != total_credits:
errors.append(UnbalancedEntryError(
source={'created_by': entry.created_by},
- message=f"Entry does not balance: sum of amounts={total_amount} (must equal 0)",
+ message=f"Entry does not balance: debits={total_debits}, credits={total_credits}",
entry=entry.dict(),
- total_amount=total_amount,
- difference=total_amount
+ total_debits=total_debits,
+ total_credits=total_credits,
+ difference=total_debits - total_credits
))
return errors
diff --git a/docs/BQL-BALANCE-QUERIES.md b/docs/BQL-BALANCE-QUERIES.md
deleted file mode 100644
index d4997ab..0000000
--- a/docs/BQL-BALANCE-QUERIES.md
+++ /dev/null
@@ -1,643 +0,0 @@
-# BQL Balance Queries Implementation
-
-**Date**: November 10, 2025
-**Status**: In Progress
-**Context**: Replace manual aggregation with Beancount Query Language (BQL)
-
----
-
-## Problem
-
-Current `get_user_balance()` implementation:
-- **115 lines** of manual aggregation logic
-- Fetches **ALL** journal entries (inefficient)
-- Manual regex parsing of amounts
-- Manual looping through entries/postings
-- O(n) complexity for every balance query
-
-**Performance Impact**:
-- Every balance check fetches entire ledger
-- No database-level filtering
-- CPU-intensive parsing and aggregation
-- Scales poorly as ledger grows
-
----
-
-## Solution: Use Beancount Query Language (BQL)
-
-Beancount has a built-in query language that can efficiently:
-- Filter accounts (regex patterns)
-- Sum positions (balances)
-- Exclude transactions by flag
-- Group and aggregate
-- All processing done by Beancount engine (optimized C code)
-
----
-
-## BQL Query Design
-
-### Query 1: Get User Balance (SATS + Fiat)
-
-```sql
-SELECT
- account,
- sum(position) as balance
-WHERE
- account ~ ':User-{user_id[:8]}'
- AND (account ~ 'Payable' OR account ~ 'Receivable')
- AND flag != '!'
-GROUP BY account
-```
-
-**What this does**:
-- `account ~ ':User-abc12345'` - Match user's accounts (regex)
-- `account ~ 'Payable' OR account ~ 'Receivable'` - Only payable/receivable accounts
-- `flag != '!'` - Exclude pending transactions
-- `sum(position)` - Aggregate balances
-- `GROUP BY account` - Separate totals per account
-
-**Result Format** (from Fava API):
-```json
-{
- "data": {
- "rows": [
- ["Liabilities:Payable:User-abc12345", {"SATS": "150000", "EUR": "145.50"}],
- ["Assets:Receivable:User-abc12345", {"SATS": "50000", "EUR": "48.00"}]
- ],
- "types": [
- {"name": "account", "type": "str"},
- {"name": "balance", "type": "Position"}
- ]
- }
-}
-```
-
-### Query 2: Get All User Balances (Admin View)
-
-```sql
-SELECT
- account,
- sum(position) as balance
-WHERE
- (account ~ 'Payable:User-' OR account ~ 'Receivable:User-')
- AND flag != '!'
-GROUP BY account
-```
-
-**What this does**:
-- Match ALL user accounts (not just one user)
-- Aggregate balances per account
-- Extract user_id from account name in post-processing
-
----
-
-## Implementation Plan
-
-### Step 1: Add General BQL Query Method
-
-Add to `fava_client.py`:
-
-```python
-async def query_bql(self, query_string: str) -> Dict[str, Any]:
- """
- Execute arbitrary Beancount Query Language (BQL) query.
-
- Args:
- query_string: BQL query (e.g., "SELECT account, sum(position) WHERE ...")
-
- Returns:
- {
- "rows": [[col1, col2, ...], ...],
- "types": [{"name": "col1", "type": "str"}, ...],
- "column_names": ["col1", "col2", ...]
- }
-
- Example:
- result = await fava.query_bql("SELECT account, sum(position) WHERE account ~ 'User-abc'")
- for row in result["rows"]:
- account, balance = row
- print(f"{account}: {balance}")
- """
- try:
- async with httpx.AsyncClient(timeout=self.timeout) as client:
- response = await client.get(
- f"{self.base_url}/query",
- params={"query_string": query_string}
- )
- response.raise_for_status()
- result = response.json()
-
- # Fava returns: {"data": {"rows": [...], "types": [...]}}
- data = result.get("data", {})
- rows = data.get("rows", [])
- types = data.get("types", [])
- column_names = [t.get("name") for t in types]
-
- return {
- "rows": rows,
- "types": types,
- "column_names": column_names
- }
-
- except httpx.HTTPStatusError as e:
- logger.error(f"BQL query error: {e.response.status_code} - {e.response.text}")
- logger.error(f"Query was: {query_string}")
- raise
- except httpx.RequestError as e:
- logger.error(f"Fava connection error: {e}")
- raise
-```
-
-### Step 2: Implement BQL-Based Balance Query
-
-Add to `fava_client.py`:
-
-```python
-async def get_user_balance_bql(self, user_id: str) -> Dict[str, Any]:
- """
- Get user balance using BQL (efficient, ~10 lines vs 115 lines manual).
-
- Args:
- user_id: User ID
-
- Returns:
- {
- "balance": int (sats),
- "fiat_balances": {"EUR": Decimal("100.50")},
- "accounts": [{"account": "...", "sats": 150000}]
- }
- """
- # Build BQL query for this user's Payable/Receivable accounts
- user_id_prefix = user_id[:8]
- query = f"""
- SELECT account, sum(position) as balance
- WHERE account ~ ':User-{user_id_prefix}'
- AND (account ~ 'Payable' OR account ~ 'Receivable')
- AND flag != '!'
- GROUP BY account
- """
-
- result = await self.query_bql(query)
-
- # Process results
- total_sats = 0
- fiat_balances = {}
- accounts = []
-
- for row in result["rows"]:
- account_name, position = row
-
- # Position is a dict like {"SATS": "150000", "EUR": "145.50"}
- # or a string for single-currency
-
- if isinstance(position, dict):
- # Extract SATS
- sats_str = position.get("SATS", "0")
- sats_amount = int(sats_str) if sats_str else 0
- total_sats += sats_amount
-
- accounts.append({
- "account": account_name,
- "sats": sats_amount
- })
-
- # Extract fiat currencies
- for currency in ["EUR", "USD", "GBP"]:
- if currency in position:
- fiat_str = position[currency]
- fiat_amount = Decimal(fiat_str) if fiat_str else Decimal(0)
-
- if currency not in fiat_balances:
- fiat_balances[currency] = Decimal(0)
- fiat_balances[currency] += fiat_amount
-
- elif isinstance(position, str):
- # Single currency (parse "150000 SATS" or "145.50 EUR")
- import re
- sats_match = re.match(r'^(-?\d+)\s+SATS$', position)
- if sats_match:
- sats_amount = int(sats_match.group(1))
- total_sats += sats_amount
- accounts.append({
- "account": account_name,
- "sats": sats_amount
- })
- else:
- fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', position)
- if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
- fiat_amount = Decimal(fiat_match.group(1))
- currency = fiat_match.group(2)
-
- if currency not in fiat_balances:
- fiat_balances[currency] = Decimal(0)
- fiat_balances[currency] += fiat_amount
-
- logger.info(f"User {user_id[:8]} balance (BQL): {total_sats} sats, fiat: {dict(fiat_balances)}")
-
- return {
- "balance": total_sats,
- "fiat_balances": fiat_balances,
- "accounts": accounts
- }
-```
-
-### Step 3: Implement BQL-Based All Users Balance
-
-```python
-async def get_all_user_balances_bql(self) -> List[Dict[str, Any]]:
- """
- Get balances for all users using BQL (efficient admin view).
-
- Returns:
- [
- {
- "user_id": "abc123",
- "balance": 100000,
- "fiat_balances": {"EUR": Decimal("100.50")},
- "accounts": [...]
- },
- ...
- ]
- """
- query = """
- SELECT account, sum(position) as balance
- WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-')
- AND flag != '!'
- GROUP BY account
- """
-
- result = await self.query_bql(query)
-
- # Group by user_id
- user_data = {}
-
- for row in result["rows"]:
- account_name, position = row
-
- # Extract user_id from account name
- # Format: "Liabilities:Payable:User-abc12345" or "Assets:Receivable:User-abc12345"
- if ":User-" not in account_name:
- continue
-
- user_id_with_prefix = account_name.split(":User-")[1]
- # User ID is the first 8 chars (our standard)
- user_id = user_id_with_prefix[:8]
-
- if user_id not in user_data:
- user_data[user_id] = {
- "user_id": user_id,
- "balance": 0,
- "fiat_balances": {},
- "accounts": []
- }
-
- # Process position (same logic as single-user query)
- if isinstance(position, dict):
- sats_str = position.get("SATS", "0")
- sats_amount = int(sats_str) if sats_str else 0
- user_data[user_id]["balance"] += sats_amount
-
- user_data[user_id]["accounts"].append({
- "account": account_name,
- "sats": sats_amount
- })
-
- for currency in ["EUR", "USD", "GBP"]:
- if currency in position:
- fiat_str = position[currency]
- fiat_amount = Decimal(fiat_str) if fiat_str else Decimal(0)
-
- if currency not in user_data[user_id]["fiat_balances"]:
- user_data[user_id]["fiat_balances"][currency] = Decimal(0)
- user_data[user_id]["fiat_balances"][currency] += fiat_amount
-
- # (Handle string format similarly...)
-
- return list(user_data.values())
-```
-
----
-
-## Testing Strategy
-
-### Unit Tests
-
-```python
-# tests/test_fava_client_bql.py
-
-async def test_query_bql():
- """Test general BQL query method."""
- fava = get_fava_client()
-
- result = await fava.query_bql("SELECT account WHERE account ~ 'Assets'")
-
- assert "rows" in result
- assert "column_names" in result
- assert len(result["rows"]) > 0
-
-async def test_get_user_balance_bql():
- """Test BQL-based user balance query."""
- fava = get_fava_client()
-
- balance = await fava.get_user_balance_bql("test_user_id")
-
- assert "balance" in balance
- assert "fiat_balances" in balance
- assert "accounts" in balance
- assert isinstance(balance["balance"], int)
-
-async def test_bql_matches_manual():
- """Verify BQL results match manual aggregation (for migration)."""
- fava = get_fava_client()
- user_id = "test_user_id"
-
- # Get balance both ways
- bql_balance = await fava.get_user_balance_bql(user_id)
- manual_balance = await fava.get_user_balance(user_id)
-
- # Should match
- assert bql_balance["balance"] == manual_balance["balance"]
- assert bql_balance["fiat_balances"] == manual_balance["fiat_balances"]
-```
-
-### Integration Tests
-
-```python
-async def test_bql_performance():
- """BQL should be significantly faster than manual aggregation."""
- import time
-
- fava = get_fava_client()
- user_id = "test_user_id"
-
- # Time BQL approach
- start = time.time()
- bql_result = await fava.get_user_balance_bql(user_id)
- bql_time = time.time() - start
-
- # Time manual approach
- start = time.time()
- manual_result = await fava.get_user_balance(user_id)
- manual_time = time.time() - start
-
- logger.info(f"BQL: {bql_time:.3f}s, Manual: {manual_time:.3f}s")
-
- # BQL should be faster (or at least not slower)
- # With large ledgers, BQL should be 2-10x faster
- assert bql_time <= manual_time * 2 # Allow some variance
-```
-
----
-
-## Migration Strategy
-
-### Phase 1: Add BQL Methods (Non-Breaking)
-
-1. Add `query_bql()` method
-2. Add `get_user_balance_bql()` method
-3. Add `get_all_user_balances_bql()` method
-4. Keep existing methods unchanged
-
-**Benefit**: Can test BQL in parallel without breaking existing code.
-
-### Phase 2: Switch to BQL (Breaking Change)
-
-1. Rename old methods:
- - `get_user_balance()` → `get_user_balance_manual()` (deprecated)
- - `get_all_user_balances()` → `get_all_user_balances_manual()` (deprecated)
-
-2. Rename new methods:
- - `get_user_balance_bql()` → `get_user_balance()`
- - `get_all_user_balances_bql()` → `get_all_user_balances()`
-
-3. Update all call sites
-
-4. Test thoroughly
-
-5. Remove deprecated manual methods after 1-2 sprints
-
----
-
-## Expected Performance Improvements
-
-### Before (Manual Aggregation)
-
-```
-User balance query:
-- Fetch ALL entries: ~100-500ms (depends on ledger size)
-- Manual parsing: ~50-200ms (CPU-bound)
-- Total: 150-700ms
-```
-
-### After (BQL)
-
-```
-User balance query:
-- BQL query (filtered at source): ~20-50ms
-- Minimal parsing: ~5-10ms
-- Total: 25-60ms
-
-Improvement: 5-10x faster
-```
-
-### Scalability
-
-**Manual approach**:
-- O(n) where n = total number of entries
-- Gets slower as ledger grows
-- Fetches entire ledger every time
-
-**BQL approach**:
-- O(log n) with indexing (Beancount internal optimization)
-- Filtered at source (only user's accounts)
-- Constant time as ledger grows (for single user)
-
----
-
-## Code Reduction
-
-- **Before**: `get_user_balance()` = 115 lines
-- **After**: `get_user_balance_bql()` = ~60 lines (with comments and error handling)
-- **Net reduction**: 55 lines (~48%)
-
-- **Before**: `get_all_user_balances()` = ~100 lines
-- **After**: `get_all_user_balances_bql()` = ~70 lines
-- **Net reduction**: 30 lines (~30%)
-
-**Total code reduction**: ~85 lines across balance query methods
-
----
-
-## Risks and Mitigation
-
-### Risk 1: BQL Query Syntax Errors
-
-**Mitigation**:
-- Test queries manually in Fava UI first
-- Add comprehensive error logging
-- Validate query results format
-
-### Risk 2: Position Format Variations
-
-**Mitigation**:
-- Handle both dict and string position formats
-- Add fallback parsing
-- Log unexpected formats for investigation
-
-### Risk 3: Regression in Balance Calculations
-
-**Mitigation**:
-- Run both methods in parallel during transition
-- Compare results and log discrepancies
-- Comprehensive test suite
-
----
-
-## Test Results and Findings
-
-**Date**: November 10, 2025
-**Status**: ⚠️ **NOT FEASIBLE for Castle's Current Data Structure**
-
-### Implementation Completed
-
-1. ✅ Analyze current implementation
-2. ✅ Design BQL queries
-3. ✅ Implement `query_bql()` method (fava_client.py:494-547)
-4. ✅ Implement `get_user_balance_bql()` method (fava_client.py:549-644)
-5. ✅ Implement `get_all_user_balances_bql()` method (fava_client.py:646-747)
-6. ✅ Test against real data
-
-### Test Results
-
-**✅ BQL query execution works perfectly:**
-- Successfully queries Fava's `/query` endpoint
-- Returns structured results (rows, types, column_names)
-- Can filter accounts by regex patterns
-- Can aggregate positions using `sum(position)`
-
-**❌ Cannot access SATS balances:**
-- BQL returns EUR/USD positions correctly
-- BQL **CANNOT** access posting metadata
-- SATS values stored in `posting.meta["sats-equivalent"]`
-- No BQL syntax to query metadata fields
-
-### Root Cause: Architecture Limitation
-
-**Current Castle Ledger Structure:**
-```
-Posting format:
- Amount: -360.00 EUR ← Position (BQL can query this)
- Metadata:
- sats-equivalent: 337096 ← Metadata (BQL CANNOT query this)
-```
-
-**Test Data:**
-- User 375ec158 has 82 EUR postings
-- ALL postings have `sats-equivalent` metadata
-- ZERO postings have SATS as position amount
-- Manual method: -7,694,356 sats (from metadata)
-- BQL method: 0 sats (cannot access metadata)
-
-**BQL Limitation:**
-```sql
--- ✅ This works (queries position):
-SELECT account, sum(position) WHERE account ~ 'User-'
-
--- ❌ This is NOT possible (metadata access):
-SELECT account, sum(meta["sats-equivalent"]) WHERE account ~ 'User-'
-```
-
-### Why Manual Aggregation is Necessary
-
-1. **SATS are Castle's primary currency** for balance tracking
-2. **SATS values are in metadata**, not positions
-3. **BQL has no metadata query capability**
-4. **Must iterate through postings** to read `meta["sats-equivalent"]`
-
-### Performance: Cache Optimization is the Solution
-
-**Phase 1 Caching (Already Implemented)** provides the performance boost:
-- ✅ Account lookups cached (5min TTL)
-- ✅ Permission lookups cached (1min TTL)
-- ✅ 60-80% reduction in DB queries
-- ✅ Addresses the actual bottleneck (database queries, not aggregation)
-
-**BQL would not improve performance** because:
-- Still need to fetch all postings to read metadata
-- Aggregation is not the bottleneck (it's fast)
-- Database queries are the bottleneck (solved by caching)
-
----
-
-## Conclusion
-
-**Status**: ⚠️ **BQL Implementation Not Feasible**
-
-**Recommendation**: **Keep manual aggregation method with Phase 1 caching**
-
-**Rationale:**
-1. ✅ Caching already provides 60-80% performance improvement
-2. ✅ SATS metadata requires posting iteration regardless of query method
-3. ✅ BQL cannot access the data we need (metadata)
-4. ✅ Manual aggregation is well-tested and working correctly
-
-**BQL Methods Status**:
-- ✅ Implemented and committed as reference code
-- ⚠️ NOT used in production (cannot query SATS from metadata)
-- 📝 Kept for future consideration if ledger format changes
-
----
-
-## Future Consideration: Ledger Format Change
-
-**If** Castle's ledger format changes to use SATS as position amounts:
-
-```beancount
-; Current format (EUR position, SATS in metadata):
-2025-11-10 * "Groceries"
- Expenses:Food -360.00 EUR
- sats-equivalent: 337096
- Liabilities:Payable:User-abc 360.00 EUR
- sats-equivalent: 337096
-
-; Hypothetical future format (SATS position, EUR as cost):
-2025-11-10 * "Groceries"
- Expenses:Food -337096 SATS {360.00 EUR}
- Liabilities:Payable:User-abc 337096 SATS {360.00 EUR}
-```
-
-**Then** BQL would become feasible:
-```sql
--- Would work with SATS as position:
-SELECT account, sum(position) as balance
-WHERE account ~ 'User-' AND currency = 'SATS'
-```
-
-**Trade-offs of format change:**
-- ✅ Would enable BQL optimization
-- ✅ Aligns with "Bitcoin-first" philosophy
-- ⚠️ Requires ledger migration
-- ⚠️ Changes reporting currency (impacts existing workflows)
-- ⚠️ Beancount cost syntax has precision limitations
-
-**Recommendation**: Consider during major version upgrade or architectural redesign.
-
----
-
-## Next Steps
-
-1. ✅ Analyze current implementation
-2. ✅ Design BQL queries
-3. ✅ Implement `query_bql()` method
-4. ✅ Implement `get_user_balance_bql()` method
-5. ✅ Test against real data
-6. ✅ Implement `get_all_user_balances_bql()` method
-7. ✅ Document findings and limitations
-8. ❌ Update call sites (NOT APPLICABLE - BQL not feasible)
-9. ❌ Remove manual methods (NOT APPLICABLE - manual method is correct approach)
-
----
-
-**Implementation By**: Claude Code
-**Date**: November 10, 2025
-**Status**: ✅ **Tested and Documented** | ⚠️ **Not Feasible for Production Use**
diff --git a/docs/BQL-PRICE-NOTATION-SOLUTION.md b/docs/BQL-PRICE-NOTATION-SOLUTION.md
deleted file mode 100644
index 24cd073..0000000
--- a/docs/BQL-PRICE-NOTATION-SOLUTION.md
+++ /dev/null
@@ -1,529 +0,0 @@
-# BQL Price Notation Solution for SATS Tracking
-
-**Date**: 2025-01-12
-**Status**: Testing
-**Context**: Explore price notation as alternative to metadata for SATS tracking
-
----
-
-## Problem Recap
-
-Current approach stores SATS in metadata:
-```beancount
-2025-11-10 * "Groceries"
- Expenses:Food -360.00 EUR
- sats-equivalent: 337096
- Liabilities:Payable:User-abc 360.00 EUR
- sats-equivalent: 337096
-```
-
-**Issue**: BQL cannot access metadata, so balance queries require manual aggregation.
-
----
-
-## Solution: Use Price Notation
-
-### Proposed Format
-
-Post in actual transaction currency (EUR) with SATS as price:
-
-```beancount
-2025-11-10 * "Groceries"
- Expenses:Food -360.00 EUR @@ 337096 SATS
- Liabilities:Payable:User-abc 360.00 EUR @@ 337096 SATS
-```
-
-**What this means**:
-- Primary amount: `-360.00 EUR` (the actual transaction currency)
-- Total price: `337096 SATS` (the bitcoin equivalent value)
-- Transaction integrity preserved (posted in EUR as it occurred)
-- SATS tracked as price (queryable by BQL)
-
----
-
-## Price Notation Options
-
-### Option 1: Per-Unit Price (`@`)
-
-```beancount
-Expenses:Food -360.00 EUR @ 936.38 SATS
-```
-
-**What it means**: Each EUR is worth 936.38 SATS
-**Total calculation**: 360 × 936.38 = 337,096.8 SATS
-**Precision**: May introduce rounding (336,696.8 vs 337,096)
-
-### Option 2: Total Price (`@@`) ✅ RECOMMENDED
-
-```beancount
-Expenses:Food -360.00 EUR @@ 337096 SATS
-```
-
-**What it means**: Total transaction value is 337,096 SATS
-**Total calculation**: Exact 337,096 SATS (no rounding)
-**Precision**: Preserves exact SATS amount from original calculation
-
-**Why `@@` is better for Castle:**
-- ✅ Preserves exact SATS amount (no rounding errors)
-- ✅ Matches current metadata storage exactly
-- ✅ Clearer intent: "this transaction equals X SATS total"
-
----
-
-## How BQL Handles Prices
-
-### Available Price Columns
-
-From BQL schema:
-- `price_number` - The numeric price amount (Decimal)
-- `price_currency` - The currency of the price (str)
-- `position` - Full posting (includes price)
-- `WEIGHT(position)` - Function that returns balance weight
-
-### BQL Query Capabilities
-
-**Test Query 1: Access price directly**
-```sql
-SELECT account, number, currency, price_number, price_currency
-WHERE account ~ 'User-375ec158'
- AND price_currency = 'SATS';
-```
-
-**Expected Result** (if price notation works):
-```json
-{
- "rows": [
- ["Liabilities:Payable:User-abc", "360.00", "EUR", "337096", "SATS"]
- ]
-}
-```
-
-**Test Query 2: Aggregate SATS from prices**
-```sql
-SELECT account,
- SUM(price_number) as total_sats
-WHERE account ~ 'User-'
- AND price_currency = 'SATS'
- AND flag != '!'
-GROUP BY account;
-```
-
-**Expected Result**:
-```json
-{
- "rows": [
- ["Liabilities:Payable:User-abc", "337096"]
- ]
-}
-```
-
----
-
-## Testing Plan
-
-### Step 1: Run Metadata Test
-
-```bash
-cd /home/padreug/projects/castle-beancounter
-./test_metadata_simple.sh
-```
-
-**What to look for**:
-- Does `meta` column exist in response?
-- Is `sats-equivalent` accessible in the data?
-
-**If YES**: Metadata IS accessible, simpler solution available
-**If NO**: Proceed with price notation approach
-
-### Step 2: Test Current Data Structure
-
-```bash
-./test_bql_metadata.sh
-```
-
-This runs 6 tests:
-1. Check metadata column
-2. Check price columns
-3. Basic position query
-4. Test WEIGHT function
-5. Aggregate positions
-6. Aggregate weights
-
-**What to look for**:
-- Which columns are available?
-- What does `position` return for entries with prices?
-- Can we access `price_number` and `price_currency`?
-
-### Step 3: Create Test Ledger Entry
-
-Add one test entry to your ledger:
-
-```beancount
-2025-01-12 * "TEST: Price notation test"
- Expenses:Test:PriceNotation -100.00 EUR @@ 93600 SATS
- Liabilities:Payable:User-TEST 100.00 EUR @@ 93600 SATS
-```
-
-Then query:
-```bash
-curl -s "http://localhost:3333/castle-ledger/api/query" \
- -G \
- --data-urlencode "query_string=SELECT account, position, price_number, price_currency WHERE account ~ 'TEST'" \
- | jq '.'
-```
-
-**Expected if working**:
-```json
-{
- "data": {
- "rows": [
- ["Expenses:Test:PriceNotation", "-100.00 EUR @@ 93600 SATS", "93600", "SATS"],
- ["Liabilities:Payable:User-TEST", "100.00 EUR @@ 93600 SATS", "93600", "SATS"]
- ],
- "types": [
- {"name": "account", "type": "str"},
- {"name": "position", "type": "Position"},
- {"name": "price_number", "type": "Decimal"},
- {"name": "price_currency", "type": "str"}
- ]
- }
-}
-```
-
----
-
-## Migration Strategy (If Price Notation Works)
-
-### Phase 1: Test on Sample Data
-
-1. Create test ledger with mix of formats
-2. Verify BQL can query price_number
-3. Verify aggregation accuracy
-4. Compare with manual method results
-
-### Phase 2: Write Migration Script
-
-```python
-#!/usr/bin/env python3
-"""
-Migrate metadata sats-equivalent to price notation.
-
-Converts:
- Expenses:Food -360.00 EUR
- sats-equivalent: 337096
-
-To:
- Expenses:Food -360.00 EUR @@ 337096 SATS
-"""
-
-import re
-from pathlib import Path
-
-def migrate_entry(entry_lines):
- """Migrate a single transaction entry."""
- result = []
- current_posting = None
- sats_value = None
-
- for line in entry_lines:
- # Check if this is a posting line
- if re.match(r'^\s{2,}\w+:', line):
- # If we have pending sats from previous posting, add it
- if current_posting and sats_value:
- # Add @@ notation to posting
- posting = current_posting.rstrip()
- posting += f" @@ {sats_value} SATS\n"
- result.append(posting)
- current_posting = None
- sats_value = None
- else:
- if current_posting:
- result.append(current_posting)
- current_posting = line
-
- # Check if this is sats-equivalent metadata
- elif 'sats-equivalent:' in line:
- match = re.search(r'sats-equivalent:\s*(-?\d+)', line)
- if match:
- sats_value = match.group(1)
- # Don't include metadata line in result
-
- else:
- # Other lines (date, narration, other metadata)
- if current_posting and sats_value:
- posting = current_posting.rstrip()
- posting += f" @@ {sats_value} SATS\n"
- result.append(posting)
- current_posting = None
- sats_value = None
- elif current_posting:
- result.append(current_posting)
- current_posting = None
-
- result.append(line)
-
- # Handle last posting
- if current_posting and sats_value:
- posting = current_posting.rstrip()
- posting += f" @@ {sats_value} SATS\n"
- result.append(posting)
- elif current_posting:
- result.append(current_posting)
-
- return result
-
-def migrate_ledger(input_file, output_file):
- """Migrate entire ledger file."""
- with open(input_file, 'r') as f:
- lines = f.readlines()
-
- result = []
- current_entry = []
- in_transaction = False
-
- for line in lines:
- # Transaction start
- if re.match(r'^\d{4}-\d{2}-\d{2}\s+[*!]', line):
- in_transaction = True
- current_entry = [line]
-
- # Empty line ends transaction
- elif in_transaction and line.strip() == '':
- current_entry.append(line)
- migrated = migrate_entry(current_entry)
- result.extend(migrated)
- current_entry = []
- in_transaction = False
-
- # Inside transaction
- elif in_transaction:
- current_entry.append(line)
-
- # Outside transaction
- else:
- result.append(line)
-
- # Handle last entry if file doesn't end with blank line
- if current_entry:
- migrated = migrate_entry(current_entry)
- result.extend(migrated)
-
- with open(output_file, 'w') as f:
- f.writelines(result)
-
-if __name__ == '__main__':
- import sys
- if len(sys.argv) != 3:
- print("Usage: migrate_ledger.py ")
- sys.exit(1)
-
- migrate_ledger(sys.argv[1], sys.argv[2])
- print(f"Migrated {sys.argv[1]} -> {sys.argv[2]}")
-```
-
-### Phase 3: Update Balance Query Methods
-
-Replace `get_user_balance_bql()` with price-based version:
-
-```python
-async def get_user_balance_bql(self, user_id: str) -> Dict[str, Any]:
- """
- Get user balance using price notation (SATS stored as @@ price).
-
- Returns:
- {
- "balance": int (sats from price_number),
- "fiat_balances": {"EUR": Decimal("100.50")},
- "accounts": [{"account": "...", "sats": 150000}]
- }
- """
- user_id_prefix = user_id[:8]
-
- # Query: Get EUR positions with SATS prices
- query = f"""
- SELECT
- account,
- number as eur_amount,
- price_number as sats_amount
- WHERE account ~ ':User-{user_id_prefix}'
- AND (account ~ 'Payable' OR account ~ 'Receivable')
- AND flag != '!'
- AND price_currency = 'SATS'
- """
-
- result = await self.query_bql(query)
-
- total_sats = 0
- fiat_balances = {}
- accounts_map = {}
-
- for row in result["rows"]:
- account_name, eur_amount, sats_amount = row
-
- # Parse amounts
- sats = int(Decimal(sats_amount)) if sats_amount else 0
- eur = Decimal(eur_amount) if eur_amount else Decimal(0)
-
- total_sats += sats
-
- # Aggregate fiat
- if eur != 0:
- if "EUR" not in fiat_balances:
- fiat_balances["EUR"] = Decimal(0)
- fiat_balances["EUR"] += eur
-
- # Track per account
- if account_name not in accounts_map:
- accounts_map[account_name] = {"account": account_name, "sats": 0}
- accounts_map[account_name]["sats"] += sats
-
- return {
- "balance": total_sats,
- "fiat_balances": fiat_balances,
- "accounts": list(accounts_map.values())
- }
-```
-
-### Phase 4: Validation
-
-1. Run both methods in parallel
-2. Compare results for all users
-3. Log any discrepancies
-4. Investigate and fix differences
-5. Once validated, switch to BQL method
-
----
-
-## Advantages of Price Notation Approach
-
-### 1. BQL Compatibility ✅
-- `price_number` is a standard BQL column
-- Can aggregate: `SUM(price_number)`
-- Can filter: `WHERE price_currency = 'SATS'`
-
-### 2. Transaction Integrity ✅
-- Post in actual transaction currency (EUR)
-- SATS as secondary value (price)
-- Proper accounting: source currency preserved
-
-### 3. Beancount Features ✅
-- Price database automatically updated
-- Can query historical EUR/SATS rates
-- Reports can show both EUR and SATS values
-
-### 4. Performance ✅
-- BQL filters at source (no fetching all entries)
-- Direct column access (no metadata parsing)
-- Efficient aggregation (database-level)
-
-### 5. Reporting Flexibility ✅
-- Show EUR amounts in reports
-- Show SATS equivalents alongside
-- Filter by either currency
-- Calculate gains/losses if SATS price changes
-
----
-
-## Potential Issues and Solutions
-
-### Issue 1: Price vs Cost Confusion
-
-**Problem**: Beancount distinguishes between `@` price and `{}` cost
-**Solution**: Always use price (`@` or `@@`), never cost (`{}`)
-
-**Why**:
-- Cost is for tracking cost basis (investments, capital gains)
-- Price is for conversion rates (what we need)
-
-### Issue 2: Precision Loss with `@`
-
-**Problem**: Per-unit price may have rounding
-```beancount
-360.00 EUR @ 936.38 SATS = 336,696.8 SATS (not 337,096)
-```
-
-**Solution**: Always use `@@` total price
-```beancount
-360.00 EUR @@ 337096 SATS = 337,096 SATS (exact)
-```
-
-### Issue 3: Negative Numbers
-
-**Problem**: How to handle negative EUR with positive SATS?
-```beancount
--360.00 EUR @@ ??? SATS
-```
-
-**Solution**: Price is always positive (it's a rate, not an amount)
-```beancount
--360.00 EUR @@ 337096 SATS ✅ Correct
-```
-
-The sign applies to the position, price is the conversion factor.
-
-### Issue 4: Historical Data
-
-**Problem**: Existing entries have metadata, not prices
-
-**Solution**: Migration script (see Phase 2)
-- One-time conversion
-- Validate with checksums
-- Keep backup of original
-
----
-
-## Testing Checklist
-
-- [ ] Run `test_metadata_simple.sh` - Check if metadata is accessible
-- [ ] Run `test_bql_metadata.sh` - Full BQL capabilities test
-- [ ] Add test entry with `@@` notation to ledger
-- [ ] Query test entry with BQL to verify price_number access
-- [ ] Compare aggregation: metadata vs price notation
-- [ ] Test negative amounts with prices
-- [ ] Test zero amounts
-- [ ] Test multi-currency scenarios (EUR, USD with SATS prices)
-- [ ] Verify price database is populated correctly
-- [ ] Check that WEIGHT() function returns SATS value
-- [ ] Validate balances match current manual method
-
----
-
-## Decision Matrix
-
-| Criteria | Metadata | Price Notation | Winner |
-|----------|----------|----------------|--------|
-| BQL Queryable | ❌ No | ✅ Yes | Price |
-| Transaction Integrity | ✅ EUR first | ✅ EUR first | Tie |
-| SATS Precision | ✅ Exact int | ✅ Exact (with @@) | Tie |
-| Migration Effort | ✅ None | ⚠️ Script needed | Metadata |
-| Performance | ❌ Manual loop | ✅ BQL optimized | Price |
-| Beancount Standard | ⚠️ Non-standard | ✅ Standard feature | Price |
-| Reporting Flexibility | ⚠️ Limited | ✅ Both currencies | Price |
-| Future Proof | ⚠️ Custom | ✅ Standard | Price |
-
-**Recommendation**: **Price Notation** if tests confirm BQL can access `price_number`
-
----
-
-## Next Steps
-
-1. **Run tests** (test_metadata_simple.sh and test_bql_metadata.sh)
-2. **Review results** - Can BQL access price_number?
-3. **Add test entry** with @@ notation
-4. **Query test entry** - Verify aggregation works
-5. **If successful**:
- - Write full migration script
- - Test on copy of production ledger
- - Validate balances match
- - Schedule migration (maintenance window)
- - Update balance query methods
- - Deploy and monitor
-6. **If unsuccessful**:
- - Document why price notation doesn't work
- - Consider Beancount plugin approach
- - Or accept manual aggregation with caching
-
----
-
-**Document Status**: Awaiting test results
-**Next Action**: Run test scripts and report findings
diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md
index ac79f03..936802b 100644
--- a/docs/DOCUMENTATION.md
+++ b/docs/DOCUMENTATION.md
@@ -71,7 +71,8 @@ CREATE TABLE entry_lines (
id TEXT PRIMARY KEY,
journal_entry_id TEXT NOT NULL,
account_id TEXT NOT NULL,
- amount INTEGER NOT NULL, -- Amount in satoshis (positive = debit, negative = credit)
+ debit INTEGER NOT NULL DEFAULT 0, -- Amount in satoshis
+ credit INTEGER NOT NULL DEFAULT 0, -- Amount in satoshis
description TEXT,
metadata TEXT DEFAULT '{}' -- JSON: {fiat_currency, fiat_amount, fiat_rate, btc_rate}
);
@@ -313,20 +314,17 @@ for account in user_accounts:
total_balance -= account_balance # Positive asset = User owes Castle, so negative balance
# Calculate fiat balance from metadata
- # Beancount-style: positive amount = debit, negative amount = credit
for line in account_entry_lines:
if line.metadata.fiat_currency and line.metadata.fiat_amount:
if account.account_type == AccountType.LIABILITY:
- # For liabilities, negative amounts (credits) increase what castle owes
- if line.amount < 0:
+ if line.credit > 0:
fiat_balances[currency] += fiat_amount # Castle owes more
- else:
+ elif line.debit > 0:
fiat_balances[currency] -= fiat_amount # Castle owes less
elif account.account_type == AccountType.ASSET:
- # For assets, positive amounts (debits) increase what user owes
- if line.amount > 0:
+ if line.debit > 0:
fiat_balances[currency] -= fiat_amount # User owes more (negative balance)
- else:
+ elif line.credit > 0:
fiat_balances[currency] += fiat_amount # User owes less
```
@@ -769,8 +767,10 @@ async def export_beancount(
beancount_name = format_account_name(account.name, account.user_id)
beancount_type = map_account_type(account.account_type)
- # Beancount-style: amount is already signed (positive = debit, negative = credit)
- amount = line.amount
+ if line.debit > 0:
+ amount = line.debit
+ else:
+ amount = -line.credit
lines.append(f" {beancount_type}:{beancount_name} {amount} SATS")
diff --git a/docs/EXPENSE_APPROVAL.md b/docs/EXPENSE_APPROVAL.md
index 3123b32..b8b3261 100644
--- a/docs/EXPENSE_APPROVAL.md
+++ b/docs/EXPENSE_APPROVAL.md
@@ -41,7 +41,7 @@ Only entries with `flag='*'` (CLEARED) are included in balance calculations:
```sql
-- Balance query excludes pending/flagged/voided entries
-SELECT SUM(amount)
+SELECT SUM(debit), SUM(credit)
FROM entry_lines el
JOIN journal_entries je ON el.journal_entry_id = je.id
WHERE el.account_id = :account_id
diff --git a/docs/PERMISSIONS-SYSTEM.md b/docs/PERMISSIONS-SYSTEM.md
deleted file mode 100644
index c3c88b7..0000000
--- a/docs/PERMISSIONS-SYSTEM.md
+++ /dev/null
@@ -1,861 +0,0 @@
-# Castle Permissions System - Overview & Administration Guide
-
-**Date**: November 10, 2025
-**Status**: 📚 **Documentation** + 🔧 **Improvement Recommendations**
-
----
-
-## Executive Summary
-
-Castle implements a **granular, hierarchical permission system** that controls who can access which accounts and perform what actions. The system supports permission inheritance, making it easy to grant access to entire account hierarchies with a single permission.
-
-**Key Features:**
-- ✅ **Three permission levels**: READ, SUBMIT_EXPENSE, MANAGE
-- ✅ **Hierarchical inheritance**: Permission on parent → access to all children
-- ✅ **Expiration support**: Time-limited permissions
-- ✅ **Caching**: 1-minute TTL for performance
-- ✅ **Audit trail**: Track who granted permissions and when
-
----
-
-## Permission Types
-
-### 1. READ
-**Purpose**: View account balances and transaction history
-
-**Capabilities**:
-- View account balance
-- See transaction history for the account
-- List sub-accounts (if hierarchical)
-
-**Use cases**:
-- Transparency for community members
-- Auditors reviewing finances
-- Users checking their own balances
-
-**Example**:
-```python
-# Grant read access to view food expenses
-await create_account_permission(
- user_id="user123",
- account_id="expenses_food_account_id",
- permission_type=PermissionType.READ
-)
-```
-
-### 2. SUBMIT_EXPENSE
-**Purpose**: Submit expenses against an account
-
-**Capabilities**:
-- Submit new expense entries
-- Create transactions that debit the account
-- Automatically creates user receivable/payable entries
-
-**Use cases**:
-- Members submitting food expenses
-- Workers logging accommodation costs
-- Contributors recording service expenses
-
-**Example**:
-```python
-# Grant permission to submit food expenses
-await create_account_permission(
- user_id="user123",
- account_id="expenses_food_account_id",
- permission_type=PermissionType.SUBMIT_EXPENSE
-)
-
-# User can now submit:
-# Debit: Expenses:Food:Groceries 100 EUR
-# Credit: Liabilities:Payable:User-user123 100 EUR
-```
-
-### 3. MANAGE
-**Purpose**: Administrative control over an account
-
-**Capabilities**:
-- Modify account settings
-- Change account description/metadata
-- Grant permissions to other users (delegated administration)
-- Archive/close accounts
-
-**Use cases**:
-- Department heads managing their budgets
-- Admins delegating permission management
-- Account owners controlling access
-
-**Example**:
-```python
-# Grant full management rights to department head
-await create_account_permission(
- user_id="dept_head",
- account_id="expenses_marketing_account_id",
- permission_type=PermissionType.MANAGE
-)
-```
-
----
-
-## Hierarchical Inheritance
-
-### How It Works
-
-Permissions on **parent accounts automatically apply to all child accounts**.
-
-**Hierarchy Example:**
-```
-Expenses:Food
-├── Expenses:Food:Groceries
-├── Expenses:Food:Restaurants
-└── Expenses:Food:Cafeteria
-```
-
-**Permission on Parent:**
-```python
-# Grant SUBMIT_EXPENSE on "Expenses:Food"
-await create_account_permission(
- user_id="alice",
- account_id="expenses_food_id",
- permission_type=PermissionType.SUBMIT_EXPENSE
-)
-```
-
-**Result:** Alice can now submit expenses to:
-- ✅ `Expenses:Food`
-- ✅ `Expenses:Food:Groceries` (inherited)
-- ✅ `Expenses:Food:Restaurants` (inherited)
-- ✅ `Expenses:Food:Cafeteria` (inherited)
-
-### Implementation
-
-The `get_user_permissions_with_inheritance()` function checks for both direct and inherited permissions:
-
-```python
-async def get_user_permissions_with_inheritance(
- user_id: str, account_name: str, permission_type: PermissionType
-) -> list[tuple[AccountPermission, Optional[str]]]:
- """
- Returns: [(permission, parent_account_name or None)]
-
- Example:
- Checking permission on "Expenses:Food:Groceries"
- User has permission on "Expenses:Food"
-
- Returns: [(permission_obj, "Expenses:Food")]
- """
- user_permissions = await get_user_permissions(user_id, permission_type)
-
- applicable_permissions = []
- for perm in user_permissions:
- account = await get_account(perm.account_id)
-
- if account_name == account.name:
- # Direct permission
- applicable_permissions.append((perm, None))
- elif account_name.startswith(account.name + ":"):
- # Inherited from parent
- applicable_permissions.append((perm, account.name))
-
- return applicable_permissions
-```
-
-**Benefits:**
-- Grant one permission → access to entire subtree
-- Easier administration (fewer permissions to manage)
-- Natural organizational structure
-- Can still override with specific permissions on children
-
----
-
-## Permission Lifecycle
-
-### 1. Granting Permission
-
-**Admin grants permission:**
-```python
-await create_account_permission(
- data=CreateAccountPermission(
- user_id="alice",
- account_id="expenses_food_id",
- permission_type=PermissionType.SUBMIT_EXPENSE,
- expires_at=None, # No expiration
- notes="Food coordinator for Q1 2025"
- ),
- granted_by="admin_user_id"
-)
-```
-
-**Result:**
-- Permission stored in DB
-- Cache invalidated for user
-- Audit trail recorded (who, when)
-
-### 2. Checking Permission
-
-**Before allowing expense submission:**
-```python
-# Check if user can submit expense to account
-permissions = await get_user_permissions_with_inheritance(
- user_id="alice",
- account_name="Expenses:Food:Groceries",
- permission_type=PermissionType.SUBMIT_EXPENSE
-)
-
-if not permissions:
- raise HTTPException(403, "Permission denied")
-
-# Permission found - allow operation
-```
-
-**Performance:** First check hits DB, subsequent checks hit cache (1min TTL)
-
-### 3. Permission Expiration
-
-**Automatic expiration check:**
-```python
-# get_user_permissions() automatically filters expired permissions
-SELECT * FROM account_permissions
-WHERE user_id = :user_id
-AND permission_type = :permission_type
-AND (expires_at IS NULL OR expires_at > NOW()) ← Automatic filtering
-```
-
-**Time-limited permission example:**
-```python
-await create_account_permission(
- data=CreateAccountPermission(
- user_id="contractor",
- account_id="expenses_temp_id",
- permission_type=PermissionType.SUBMIT_EXPENSE,
- expires_at=datetime(2025, 12, 31), # Expires end of year
- notes="Temporary contractor access"
- ),
- granted_by="admin"
-)
-```
-
-### 4. Revoking Permission
-
-**Manual revocation:**
-```python
-await delete_account_permission(permission_id="perm123")
-```
-
-**Result:**
-- Permission deleted from DB
-- Cache invalidated for user
-- User immediately loses access (after cache TTL)
-
----
-
-## Caching Strategy
-
-### Cache Configuration
-
-```python
-# Cache for permission lookups
-permission_cache = Cache(default_ttl=60) # 1 minute TTL
-
-# Cache keys:
-# - "permissions:user:{user_id}" → All permissions for user
-# - "permissions:user:{user_id}:{permission_type}" → Filtered by type
-```
-
-**Why 1 minute TTL?**
-- Permissions may change frequently (grant/revoke)
-- Security-sensitive data needs to be fresh
-- Balance between performance and accuracy
-
-### Cache Invalidation
-
-**On permission creation:**
-```python
-# Invalidate both general and type-specific caches
-permission_cache._values.pop(f"permissions:user:{user_id}", None)
-permission_cache._values.pop(f"permissions:user:{user_id}:{permission_type.value}", None)
-```
-
-**On permission deletion:**
-```python
-# Get permission first to know which user's cache to clear
-permission = await get_account_permission(permission_id)
-await db.execute("DELETE FROM account_permissions WHERE id = :id", {"id": permission_id})
-
-# Invalidate caches
-permission_cache._values.pop(f"permissions:user:{permission.user_id}", None)
-permission_cache._values.pop(f"permissions:user:{permission.user_id}:{permission.permission_type.value}", None)
-```
-
-**Performance Impact:**
-- Cold cache: ~50ms (DB query)
-- Warm cache: ~1ms (memory lookup)
-- **Reduction**: 60-80% fewer DB queries
-
----
-
-## Administration Best Practices
-
-### 1. Use Hierarchical Permissions
-
-**❌ Don't do this:**
-```python
-# Granting 10 separate permissions (hard to manage)
-await create_account_permission(user, "Expenses:Food:Groceries", SUBMIT_EXPENSE)
-await create_account_permission(user, "Expenses:Food:Restaurants", SUBMIT_EXPENSE)
-await create_account_permission(user, "Expenses:Food:Cafeteria", SUBMIT_EXPENSE)
-await create_account_permission(user, "Expenses:Food:Snacks", SUBMIT_EXPENSE)
-# ... 6 more
-```
-
-**✅ Do this instead:**
-```python
-# Single permission covers all children
-await create_account_permission(user, "Expenses:Food", SUBMIT_EXPENSE)
-```
-
-**Benefits:**
-- Fewer permissions to track
-- Easier to revoke (one permission vs many)
-- Automatically covers new sub-accounts
-- Cleaner audit trail
-
-### 2. Use Expiration for Temporary Access
-
-**❌ Don't do this:**
-```python
-# Grant permanent access to temp worker
-await create_account_permission(user, account, SUBMIT_EXPENSE)
-# ... then forget to revoke when they leave
-```
-
-**✅ Do this instead:**
-```python
-# Auto-expiring permission
-await create_account_permission(
- user,
- account,
- SUBMIT_EXPENSE,
- expires_at=contract_end_date, # Automatic cleanup
- notes="Contractor until 2025-12-31"
-)
-```
-
-**Benefits:**
-- No manual cleanup needed
-- Reduced security risk
-- Self-documenting access period
-- Admin can still revoke early if needed
-
-### 3. Use Notes for Audit Trail
-
-**❌ Don't do this:**
-```python
-# No context
-await create_account_permission(user, account, SUBMIT_EXPENSE)
-```
-
-**✅ Do this instead:**
-```python
-# Clear documentation
-await create_account_permission(
- user,
- account,
- SUBMIT_EXPENSE,
- notes="Food coordinator for Q1 2025 - approved in meeting 2025-01-05"
-)
-```
-
-**Benefits:**
-- Future admins understand why permission exists
-- Audit trail for compliance
-- Easier to review permissions
-- Can reference approval process
-
-### 4. Principle of Least Privilege
-
-**Start with READ, escalate only if needed:**
-
-```python
-# Initial access: READ only
-await create_account_permission(user, account, PermissionType.READ)
-
-# If user needs to submit expenses, upgrade:
-await create_account_permission(user, account, PermissionType.SUBMIT_EXPENSE)
-
-# Only grant MANAGE to trusted users:
-await create_account_permission(dept_head, account, PermissionType.MANAGE)
-```
-
-**Security principle:** Grant minimum permissions needed for the task.
-
----
-
-## Current Implementation Strengths
-
-✅ **Well-designed features:**
-1. **Hierarchical inheritance** - Reduces admin burden
-2. **Type safety** - Enum-based permission types prevent typos
-3. **Caching** - Good performance without sacrificing security
-4. **Expiration support** - Automatic cleanup of temporary access
-5. **Audit trail** - Tracks who granted permissions and when
-6. **Foreign key constraints** - Cannot grant permission on non-existent account
-
----
-
-## Improvement Opportunities
-
-### 🔧 Opportunity 1: Permission Groups/Roles
-
-**Current limitation:** Must grant permissions individually
-
-**Proposed enhancement:**
-```python
-# Define reusable permission groups
-ROLE_FOOD_COORDINATOR = [
- (PermissionType.READ, "Expenses:Food"),
- (PermissionType.SUBMIT_EXPENSE, "Expenses:Food"),
- (PermissionType.MANAGE, "Expenses:Food:Groceries"),
-]
-
-# Grant entire role at once
-await grant_role(user_id="alice", role=ROLE_FOOD_COORDINATOR)
-```
-
-**Benefits:**
-- Standard permission sets
-- Easier onboarding
-- Consistent access patterns
-- Bulk grant/revoke
-
-**Implementation effort:** 1-2 days
-
----
-
-### 🔧 Opportunity 2: Permission Templates
-
-**Current limitation:** No way to clone permissions from one user to another
-
-**Proposed enhancement:**
-```python
-# Copy all permissions from one user to another
-await copy_permissions(
- from_user="experienced_coordinator",
- to_user="new_coordinator",
- permission_types=[PermissionType.SUBMIT_EXPENSE], # Optional filter
- notes="Copied from Alice - new food coordinator"
-)
-```
-
-**Benefits:**
-- Faster onboarding
-- Consistency
-- Reduces errors
-- Preserves expiration patterns
-
-**Implementation effort:** 1 day
-
----
-
-### 🔧 Opportunity 3: Bulk Permission Management
-
-**Current limitation:** One permission at a time
-
-**Proposed enhancement:**
-```python
-# Grant same permission to multiple users
-await bulk_grant_permission(
- user_ids=["alice", "bob", "charlie"],
- account_id="expenses_food_id",
- permission_type=PermissionType.SUBMIT_EXPENSE,
- expires_at=datetime(2025, 12, 31),
- notes="Q4 food team"
-)
-
-# Revoke all permissions on an account
-await revoke_all_permissions_on_account(account_id="old_project_id")
-
-# Revoke all permissions for a user (offboarding)
-await revoke_all_user_permissions(user_id="departed_user")
-```
-
-**Benefits:**
-- Faster administration
-- Consistent permission sets
-- Easy offboarding
-- Bulk operations for events/projects
-
-**Implementation effort:** 2 days
-
----
-
-### 🔧 Opportunity 4: Permission Analytics Dashboard
-
-**Current limitation:** No visibility into permission usage
-
-**Proposed enhancement:**
-```python
-# Admin endpoint for permission analytics
-@router.get("/api/v1/admin/permissions/analytics")
-async def get_permission_analytics():
- return {
- "total_permissions": 150,
- "by_type": {
- "READ": 50,
- "SUBMIT_EXPENSE": 80,
- "MANAGE": 20
- },
- "expiring_soon": [
- {"user_id": "alice", "account": "Expenses:Food", "expires": "2025-11-15"},
- # ... more
- ],
- "most_permissioned_accounts": [
- {"account": "Expenses:Food", "permission_count": 25},
- # ... more
- ],
- "users_without_permissions": ["bob", "charlie"], # Alert for review
- "orphaned_permissions": [] # Permissions on deleted accounts
- }
-```
-
-**Benefits:**
-- Visibility into access patterns
-- Proactive expiration management
-- Security audit support
-- Identify unused permissions
-
-**Implementation effort:** 2-3 days
-
----
-
-### 🔧 Opportunity 5: Permission Request Workflow
-
-**Current limitation:** Users must ask admin manually to grant permissions
-
-**Proposed enhancement:**
-```python
-# User requests permission
-await request_permission(
- user_id="alice",
- account_id="expenses_food_id",
- permission_type=PermissionType.SUBMIT_EXPENSE,
- justification="I'm the new food coordinator starting next week"
-)
-
-# Admin reviews and approves
-pending = await get_pending_permission_requests()
-await approve_permission_request(request_id="req123", admin_user_id="admin")
-
-# Or deny with reason
-await deny_permission_request(
- request_id="req456",
- admin_user_id="admin",
- reason="Please request via department head first"
-)
-```
-
-**Benefits:**
-- Self-service permission requests
-- Audit trail for approvals
-- Reduces admin manual work
-- Transparent process
-
-**Implementation effort:** 3-4 days
-
----
-
-### 🔧 Opportunity 6: Permission Monitoring & Alerts
-
-**Current limitation:** No alerts for security events
-
-**Proposed enhancement:**
-```python
-# Monitor and alert on permission changes
-class PermissionMonitor:
- async def on_permission_granted(self, permission):
- # Alert if MANAGE permission granted
- if permission.permission_type == PermissionType.MANAGE:
- await send_admin_alert(
- f"MANAGE permission granted to {permission.user_id} on {account.name}"
- )
-
- async def on_permission_expired(self, permission):
- # Alert user their access is expiring
- await send_user_notification(
- user_id=permission.user_id,
- message=f"Your access to {account.name} expires in 7 days"
- )
-
- async def on_suspicious_activity(self, user_id, account_id):
- # Alert on unusual permission usage patterns
- if failed_permission_checks > 5:
- await send_admin_alert(
- f"User {user_id} attempted access to {account_id} 5 times (denied)"
- )
-```
-
-**Benefits:**
-- Security monitoring
-- Proactive expiration management
-- Detect permission issues early
-- Compliance support
-
-**Implementation effort:** 2-3 days
-
----
-
-## Recommended Implementation Priority
-
-### Phase 1: Quick Wins (1 week)
-1. **Bulk Permission Management** (2 days) - Immediate productivity boost
-2. **Permission Templates** (1 day) - Easy onboarding
-3. **Permission Analytics** (2 days) - Visibility and audit support
-
-**Total effort**: 5 days
-**Impact**: High (reduces admin time by 50%)
-
-### Phase 2: Process Improvements (1 week)
-4. **Permission Request Workflow** (3-4 days) - Self-service
-5. **Permission Groups/Roles** (2 days) - Standardization
-
-**Total effort**: 5-6 days
-**Impact**: Medium (better user experience)
-
-### Phase 3: Security & Compliance (1 week)
-6. **Permission Monitoring & Alerts** (2-3 days) - Security
-7. **Audit log enhancements** (2 days) - Compliance
-8. **Permission review workflow** (2 days) - Periodic access review
-
-**Total effort**: 6-7 days
-**Impact**: Medium (security & compliance)
-
----
-
-## API Reference
-
-### Grant Permission
-```python
-POST /api/v1/permissions
-{
- "user_id": "alice",
- "account_id": "acc123",
- "permission_type": "submit_expense",
- "expires_at": "2025-12-31T23:59:59",
- "notes": "Food coordinator Q4"
-}
-```
-
-### Get User Permissions
-```python
-GET /api/v1/permissions/user/{user_id}
-GET /api/v1/permissions/user/{user_id}?type=submit_expense
-```
-
-### Get Account Permissions
-```python
-GET /api/v1/permissions/account/{account_id}
-```
-
-### Revoke Permission
-```python
-DELETE /api/v1/permissions/{permission_id}
-```
-
-### Check Permission (with inheritance)
-```python
-GET /api/v1/permissions/check?user_id=alice&account=Expenses:Food:Groceries&type=submit_expense
-```
-
----
-
-## Database Schema
-
-```sql
-CREATE TABLE account_permissions (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL,
- account_id TEXT NOT NULL,
- permission_type TEXT NOT NULL,
- granted_by TEXT NOT NULL,
- granted_at TIMESTAMP NOT NULL,
- expires_at TIMESTAMP,
- notes TEXT,
-
- 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_account_id ON account_permissions (account_id);
-CREATE INDEX idx_account_permissions_expires_at ON account_permissions (expires_at);
-```
-
----
-
-## Security Considerations
-
-### 1. Permission Escalation Prevention
-
-**Risk:** User with MANAGE on child account tries to grant permissions on parent
-
-**Mitigation:**
-```python
-async def create_account_permission(data, granted_by):
- # Check granter has MANAGE permission on account (or parent)
- granter_permissions = await get_user_permissions_with_inheritance(
- granted_by, account.name, PermissionType.MANAGE
- )
- if not granter_permissions:
- raise HTTPException(403, "You don't have permission to grant access to this account")
-```
-
-### 2. Cache Timing Attacks
-
-**Risk:** Stale cache shows old permissions after revocation
-
-**Mitigation:**
-- Conservative 1-minute TTL
-- Explicit cache invalidation on writes
-- Admin can force cache clear if needed
-
-### 3. Expired Permission Cleanup
-
-**Current:** Expired permissions filtered at query time but remain in DB
-
-**Improvement:** Add background job to purge old permissions
-```python
-async def cleanup_expired_permissions():
- """Run daily to remove expired permissions"""
- await db.execute(
- "DELETE FROM account_permissions WHERE expires_at < NOW() - INTERVAL '30 days'"
- )
-```
-
----
-
-## Troubleshooting
-
-### Permission Denied Despite Valid Permission
-
-**Possible causes:**
-1. Cache not invalidated after grant
-2. Permission expired
-3. Checking wrong account name (case sensitive)
-4. Account ID mismatch
-
-**Solution:**
-```python
-# Clear cache and re-check
-permission_cache._values.clear()
-
-# Verify permission exists
-perms = await get_user_permissions(user_id)
-logger.info(f"User {user_id} permissions: {perms}")
-
-# Check with inheritance
-inherited = await get_user_permissions_with_inheritance(user_id, account_name, perm_type)
-logger.info(f"Inherited permissions: {inherited}")
-```
-
-### Performance Issues
-
-**Symptom:** Slow permission checks
-
-**Causes:**
-1. Cache not working
-2. Too many permissions per user
-3. Deep hierarchy causing many account lookups
-
-**Solution:**
-```python
-# Monitor cache hit rate
-hits = len([v for v in permission_cache._values.values() if v is not None])
-logger.info(f"Permission cache: {hits} entries")
-
-# Optimize with account cache (implemented separately)
-# Use account_cache to reduce DB queries for account lookups
-```
-
----
-
-## Testing Permissions
-
-### Unit Tests
-```python
-async def test_permission_inheritance():
- """Test that permission on parent grants access to child"""
- # Grant on parent
- await create_account_permission(
- user="alice",
- account="Expenses:Food",
- permission_type=PermissionType.SUBMIT_EXPENSE
- )
-
- # Check child access
- perms = await get_user_permissions_with_inheritance(
- "alice",
- "Expenses:Food:Groceries",
- PermissionType.SUBMIT_EXPENSE
- )
-
- assert len(perms) == 1
- assert perms[0][1] == "Expenses:Food" # Inherited from parent
-
-async def test_permission_expiration():
- """Test that expired permissions are filtered"""
- # Create expired permission
- await create_account_permission(
- user="bob",
- account="acc123",
- permission_type=PermissionType.READ,
- expires_at=datetime.now() - timedelta(days=1) # Expired yesterday
- )
-
- # Should not be returned
- perms = await get_user_permissions("bob")
- assert len(perms) == 0
-```
-
-### Integration Tests
-```python
-async def test_expense_submission_with_permission():
- """Test full flow: grant permission → submit expense"""
- # 1. Grant permission
- await create_account_permission(user, account, PermissionType.SUBMIT_EXPENSE)
-
- # 2. Submit expense
- response = await api_create_expense_entry(ExpenseEntry(...))
-
- # 3. Verify success
- assert response.status_code == 200
-
-async def test_expense_submission_without_permission():
- """Test that expense submission fails without permission"""
- # Try to submit without permission
- with pytest.raises(HTTPException) as exc:
- await api_create_expense_entry(ExpenseEntry(...))
-
- assert exc.value.status_code == 403
-```
-
----
-
-## Summary
-
-The Castle permissions system is **well-designed** with strong features:
-- Hierarchical inheritance reduces admin burden
-- Caching provides good performance
-- Expiration and audit trail support compliance
-- Type-safe enums prevent errors
-
-**Recommended next steps:**
-1. Implement **bulk permission management** (quick win)
-2. Add **permission analytics dashboard** (visibility)
-3. Consider **permission request workflow** (self-service)
-4. Monitor cache performance and security events
-
-The system is production-ready and scales well for small-to-medium deployments. For larger deployments (1000+ users), consider implementing the permission groups/roles feature for easier management.
-
----
-
-**Document Version**: 1.0
-**Last Updated**: November 10, 2025
-**Status**: Complete + Improvement Recommendations
diff --git a/docs/PHASE3_COMPLETE.md b/docs/PHASE3_COMPLETE.md
index 1a3dbb6..bce9a76 100644
--- a/docs/PHASE3_COMPLETE.md
+++ b/docs/PHASE3_COMPLETE.md
@@ -276,8 +276,8 @@ balance = BalanceCalculator.calculate_account_balance(
# Build inventory from entry lines
entry_lines = [
- {"amount": 100000, "metadata": '{"fiat_currency": "EUR", "fiat_amount": "50.00"}'}, # Positive = debit
- {"amount": -50000, "metadata": "{}"} # Negative = credit
+ {"debit": 100000, "credit": 0, "metadata": '{"fiat_currency": "EUR", "fiat_amount": "50.00"}'},
+ {"debit": 0, "credit": 50000, "metadata": "{}"}
]
inventory = BalanceCalculator.build_inventory_from_entry_lines(
@@ -306,8 +306,8 @@ entry = {
}
entry_lines = [
- {"account_id": "acc1", "amount": 100000}, # Positive = debit
- {"account_id": "acc2", "amount": -100000} # Negative = credit
+ {"account_id": "acc1", "debit": 100000, "credit": 0},
+ {"account_id": "acc2", "debit": 0, "credit": 100000}
]
try:
diff --git a/docs/SATS-EQUIVALENT-METADATA.md b/docs/SATS-EQUIVALENT-METADATA.md
deleted file mode 100644
index 48ab36c..0000000
--- a/docs/SATS-EQUIVALENT-METADATA.md
+++ /dev/null
@@ -1,386 +0,0 @@
-# SATS-Equivalent Metadata Field
-
-**Date**: 2025-01-12
-**Status**: Current Architecture
-**Location**: Beancount posting metadata
-
----
-
-## Overview
-
-The `sats-equivalent` metadata field is Castle's solution for **dual-currency tracking** in a fiat-denominated ledger. It preserves Bitcoin (satoshi) amounts alongside fiat currency amounts without violating accounting principles or creating multi-currency complexity in position balances.
-
-### Quick Summary
-
-- **Purpose**: Track Bitcoin/Lightning amounts in a EUR-denominated ledger
-- **Location**: Beancount posting metadata (not position amounts)
-- **Format**: String containing absolute satoshi amount (e.g., `"337096"`)
-- **Primary Use**: Calculate user balances in satoshis (Castle's primary currency)
-- **Key Principle**: Satoshis are for reference; EUR is the actual transaction currency
-
----
-
-## The Problem: Dual-Currency Tracking
-
-Castle needs to track both:
-1. **Fiat amounts** (EUR, USD) - The actual transaction currency
-2. **Bitcoin amounts** (satoshis) - The Lightning Network settlement currency
-
-### Why Not Just Use SATS as Position Amounts?
-
-**Accounting Reality**: When a user pays €36.93 cash for groceries, the transaction is denominated in EUR, not Bitcoin. Recording it as Bitcoin would:
-- ❌ Misrepresent the actual transaction
-- ❌ Create exchange rate volatility issues
-- ❌ Complicate traditional accounting reconciliation
-- ❌ Make fiat-based reporting difficult
-
-**Castle's Philosophy**: Record transactions in their **actual currency**, with Bitcoin as supplementary data.
-
----
-
-## Architecture: EUR-Primary Format
-
-### Current Ledger Format
-
-```beancount
-2025-11-10 * "Groceries (36.93 EUR)" #expense-entry
- Expenses:Food:Supplies 36.93 EUR
- sats-equivalent: "39669"
- reference: "cash-payment-abc123"
- Liabilities:Payable:User-5987ae95 -36.93 EUR
- sats-equivalent: "39669"
-```
-
-**Key Components:**
-- **Position Amount**: `36.93 EUR` - The actual transaction amount
-- **Metadata**: `sats-equivalent: "39669"` - The Bitcoin equivalent at time of transaction
-- **Sign**: The sign (debit/credit) is on the EUR amount; sats-equivalent is always absolute value
-
-### How It's Created
-
-In `views_api.py:839`:
-
-```python
-# If fiat currency is provided, use EUR-based format
-if fiat_currency and fiat_amount:
- # EUR-based posting (current architecture)
- posting_metadata["sats-equivalent"] = str(abs(line.amount))
-
- # Apply the sign from line.amount to fiat_amount
- signed_fiat_amount = fiat_amount if line.amount >= 0 else -fiat_amount
-
- posting = {
- "account": account.name,
- "amount": f"{signed_fiat_amount:.2f} {fiat_currency}",
- "meta": posting_metadata if posting_metadata else None
- }
-```
-
-**Critical Details:**
-- `line.amount` is always in satoshis internally
-- The sign (debit/credit) transfers to the fiat amount
-- `sats-equivalent` stores the **absolute value** of the satoshi amount
-- Sign interpretation depends on account type (Asset/Liability/etc.)
-
----
-
-## Usage: Balance Calculation
-
-### Primary Use Case: User Balances
-
-Castle's core function is tracking **who owes whom** in satoshis. The `sats-equivalent` metadata enables this.
-
-**Flow** (`fava_client.py:220-248`):
-
-```python
-# Parse posting amount (EUR/USD)
-fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
-if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
- fiat_amount = Decimal(fiat_match.group(1))
- fiat_currency = fiat_match.group(2)
-
- # Track fiat balance
- fiat_balances[fiat_currency] += fiat_amount
-
- # Extract SATS equivalent from metadata
- posting_meta = posting.get("meta", {})
- sats_equiv = posting_meta.get("sats-equivalent")
- if sats_equiv:
- # Apply the sign from fiat_amount to sats_equiv
- sats_amount = int(sats_equiv) if fiat_amount > 0 else -int(sats_equiv)
- total_sats += sats_amount
-```
-
-**Sign Interpretation:**
-- EUR amount is `36.93` (positive/debit) → sats is `+39669`
-- EUR amount is `-36.93` (negative/credit) → sats is `-39669`
-
-### Secondary Use: Journal Entry Display
-
-When displaying transactions to users (`views_api.py:747-751`):
-
-```python
-# Extract sats equivalent from metadata
-posting_meta = first_posting.get("meta", {})
-sats_equiv = posting_meta.get("sats-equivalent")
-if sats_equiv:
- amount_sats = abs(int(sats_equiv))
-```
-
-This allows the UI to show both EUR and SATS amounts for each transaction.
-
----
-
-## Why Metadata Instead of Positions?
-
-### The BQL Limitation
-
-Beancount Query Language (BQL) **cannot access metadata**. This means:
-
-```sql
--- ✅ This works (queries position amounts):
-SELECT account, sum(position) WHERE account ~ 'User-5987ae95'
--- Returns: EUR positions (not useful for satoshi balances)
-
--- ❌ This is NOT possible:
-SELECT account, sum(meta["sats-equivalent"]) WHERE account ~ 'User-5987ae95'
--- Error: BQL cannot access metadata
-```
-
-### Why Castle Accepts This Trade-off
-
-**Performance Analysis** (see `docs/BQL-BALANCE-QUERIES.md`):
-1. **Caching solves the bottleneck**: 60-80% performance improvement from caching account/permission lookups
-2. **Iteration is necessary anyway**: Even with BQL, we'd need to iterate postings to access metadata
-3. **Manual aggregation is fast**: The actual summation is not the bottleneck
-4. **Database queries are the bottleneck**: Solved by Phase 1 caching, not BQL
-
-**Architectural Correctness > Query Performance**:
-- ✅ Transactions recorded in their actual currency
-- ✅ No artificial multi-currency positions
-- ✅ Clean accounting reconciliation
-- ✅ Exchange rate changes don't affect historical records
-
----
-
-## Alternative Considered: Price Notation
-
-### Price Notation Format (Not Implemented)
-
-```beancount
-2025-11-10 * "Groceries"
- Expenses:Food -360.00 EUR @@ 337096 SATS
- Liabilities:Payable:User-abc 360.00 EUR @@ 337096 SATS
-```
-
-**Pros:**
-- ✅ BQL can query prices (enables BQL aggregation)
-- ✅ Standard Beancount syntax
-- ✅ SATS trackable via price database
-
-**Cons:**
-- ❌ Semantically incorrect: `@@` means "total price paid", not "equivalent value"
-- ❌ Implies currency conversion happened (it didn't)
-- ❌ Confuses future readers about transaction nature
-- ❌ Complicates Beancount's price database
-
-**Decision**: Metadata is more semantically correct for "reference value" than price notation.
-
-See `docs/BQL-PRICE-NOTATION-SOLUTION.md` for full analysis.
-
----
-
-## Data Flow Example
-
-### User Adds Expense
-
-**User Action**: "I paid €36.93 cash for groceries"
-
-**Castle's Internal Representation**:
-```python
-# User provides or Castle calculates:
-fiat_amount = Decimal("36.93") # EUR
-fiat_currency = "EUR"
-amount_sats = 39669 # Calculated from exchange rate
-
-# Create journal entry line:
-line = CreateEntryLine(
- account_id=expense_account.id,
- amount=amount_sats, # Internal: always satoshis
- metadata={
- "fiat_currency": "EUR",
- "fiat_amount": "36.93"
- }
-)
-```
-
-**Beancount Entry Created** (`views_api.py:835-849`):
-```beancount
-2025-11-10 * "Groceries (36.93 EUR)" #expense-entry
- Expenses:Food:Supplies 36.93 EUR
- sats-equivalent: "39669"
- Liabilities:Payable:User-5987ae95 -36.93 EUR
- sats-equivalent: "39669"
-```
-
-**Balance Calculation** (`fava_client.py:get_user_balance`):
-```python
-# Iterate all postings for user accounts
-# For each posting:
-# - Parse EUR amount: -36.93 EUR (credit to liability)
-# - Extract sats-equivalent: "39669"
-# - Apply sign: -36.93 is negative → sats = -39669
-# - Accumulate: user_balance_sats += -39669
-
-# Result: negative balance = Castle owes user
-```
-
-**User Balance Response**:
-```json
-{
- "user_id": "5987ae95",
- "balance": -39669, // Castle owes user 39,669 sats
- "fiat_balances": {
- "EUR": "-36.93" // Castle owes user €36.93
- }
-}
-```
-
----
-
-## Implementation Details
-
-### Where It's Set
-
-**Primary Location**: `views_api.py:835-849` (Creating journal entries)
-
-All EUR-based postings get `sats-equivalent` metadata:
-- Expense entries (user adds liability)
-- Receivable entries (admin records what user owes)
-- Revenue entries (direct income)
-- Payment entries (settling balances)
-
-### Where It's Read
-
-**Primary Location**: `fava_client.py:239-247` (Balance calculation)
-
-Used in:
-1. `get_user_balance()` - Calculate individual user balance
-2. `get_all_user_balances()` - Calculate all user balances
-3. `get_journal_entries()` - Display transaction amounts
-
-### Data Type and Format
-
-- **Type**: String (Beancount metadata values must be strings or numbers)
-- **Format**: Absolute value, no sign, no decimal point
-- **Examples**:
- - ✅ `"39669"` (correct)
- - ✅ `"1000000"` (1M sats)
- - ❌ `"-39669"` (incorrect: sign goes on EUR amount)
- - ❌ `"396.69"` (incorrect: satoshis are integers)
-
----
-
-## Key Principles
-
-### 1. Record in Transaction Currency
-
-```beancount
-# ✅ CORRECT: User paid EUR, record in EUR
-Expenses:Food 36.93 EUR
- sats-equivalent: "39669"
-
-# ❌ WRONG: Recording Bitcoin when user paid cash
-Expenses:Food 39669 SATS {36.93 EUR}
-```
-
-### 2. Preserve Historical Values
-
-The `sats-equivalent` is the **exact satoshi amount at transaction time**. It does NOT change when exchange rates change.
-
-**Example:**
-- 2025-11-10: User pays €36.93 → 39,669 sats (rate: 1074.19 sats/EUR)
-- 2025-11-15: Exchange rate changes to 1100 sats/EUR
-- **Metadata stays**: `sats-equivalent: "39669"` ✅
-- **If we used current rate**: Would become 40,623 sats ❌
-
-### 3. Separate Fiat and Sats Balances
-
-Castle tracks TWO independent balances:
-- **Satoshi balance**: Sum of `sats-equivalent` metadata (primary)
-- **Fiat balances**: Sum of EUR/USD position amounts (secondary)
-
-These are calculated independently and don't cross-convert.
-
-### 4. Absolute Values in Metadata
-
-The sign (debit/credit) lives on the position amount, NOT the metadata.
-
-```beancount
-# Debit (expense increases):
-Expenses:Food 36.93 EUR # Positive
- sats-equivalent: "39669" # Absolute value
-
-# Credit (liability increases):
-Liabilities:Payable -36.93 EUR # Negative
- sats-equivalent: "39669" # Same absolute value
-```
-
----
-
-## Migration Path
-
-### Future: If We Change to SATS-Primary Format
-
-**Hypothetical future format:**
-```beancount
-; SATS as position, EUR as cost:
-2025-11-10 * "Groceries"
- Expenses:Food 39669 SATS {36.93 EUR}
- Liabilities:Payable:User-abc -39669 SATS {36.93 EUR}
-```
-
-**Benefits:**
-- ✅ BQL can query SATS directly
-- ✅ No metadata parsing needed
-- ✅ Standard Beancount cost accounting
-
-**Migration Script** (conceptual):
-```python
-# Read all postings with sats-equivalent metadata
-# For each posting:
-# - Extract sats from metadata
-# - Extract EUR from position
-# - Rewrite as: " SATS { EUR}"
-```
-
-**Decision**: Not implementing now because:
-1. Current architecture is semantically correct
-2. Performance is acceptable with caching
-3. Migration would break existing tooling
-4. EUR-primary aligns with accounting reality
-
----
-
-## Related Documentation
-
-- `docs/BQL-BALANCE-QUERIES.md` - Why BQL can't query metadata and performance analysis
-- `docs/BQL-PRICE-NOTATION-SOLUTION.md` - Alternative using price notation (not implemented)
-- `beancount_format.py` - Functions that create entries with sats-equivalent metadata
-- `fava_client.py:get_user_balance()` - How metadata is parsed for balance calculation
-
----
-
-## Technical Summary
-
-**Field**: `sats-equivalent`
-**Type**: Metadata (string)
-**Location**: Beancount posting metadata
-**Format**: Absolute satoshi amount as string (e.g., `"39669"`)
-**Purpose**: Track Bitcoin equivalent of fiat transactions
-**Primary Use**: Calculate user satoshi balances
-**Sign Handling**: Inherits from position amount (EUR/USD)
-**Queryable via BQL**: ❌ No (BQL cannot access metadata)
-**Performance**: ✅ Acceptable with caching (60-80% improvement)
-**Architectural Status**: ✅ Current production format
-**Future Migration**: Possible to SATS-primary if needed
diff --git a/docs/UI-IMPROVEMENTS-PLAN.md b/docs/UI-IMPROVEMENTS-PLAN.md
deleted file mode 100644
index 97bc9d3..0000000
--- a/docs/UI-IMPROVEMENTS-PLAN.md
+++ /dev/null
@@ -1,734 +0,0 @@
-# Castle UI Improvements Plan
-
-**Date**: November 10, 2025
-**Status**: 📋 **Planning Document**
-**Related**: ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md, PERMISSIONS-SYSTEM.md
-
----
-
-## Overview
-
-Enhance the Castle permissions UI to showcase new bulk permission management and account sync features, making admin tasks faster and more intuitive.
-
----
-
-## Current UI Assessment
-
-**What's Good:**
-- ✅ Clean Quasar/Vue.js structure
-- ✅ Three views: By User, By Account, Equity
-- ✅ Basic grant/revoke functionality
-- ✅ Good visual design with icons and colors
-- ✅ Admin-only protection
-
-**What's Missing:**
-- ❌ No bulk operations
-- ❌ No permission analytics dashboard
-- ❌ No permission templates/copying
-- ❌ No account sync UI
-- ❌ No user offboarding workflow
-- ❌ No expiring permissions alerts
-
----
-
-## Proposed Enhancements
-
-### 1. Add "Analytics" Tab
-
-**Purpose**: Give admins visibility into permission usage
-
-**Features:**
-- Total permissions count (by type)
-- Permissions expiring soon (7 days)
-- Most-permissioned accounts (top 10)
-- Users with/without permissions
-- Permission grant timeline chart
-
-**UI Mockup:**
-```
-┌─────────────────────────────────────────────┐
-│ 📊 Permission Analytics │
-├─────────────────────────────────────────────┤
-│ │
-│ ┌──────────────┐ ┌──────────────┐ │
-│ │ Total │ │ Expiring │ │
-│ │ 150 │ │ 5 (7 days) │ │
-│ │ Permissions │ │ │ │
-│ └──────────────┘ └──────────────┘ │
-│ │
-│ Permission Distribution │
-│ ┌────────────────────────────────────┐ │
-│ │ READ ██████ 50 (33%) │ │
-│ │ SUBMIT_EXPENSE ████████ 80 (53%) │ │
-│ │ MANAGE ████ 20 (13%) │ │
-│ └────────────────────────────────────┘ │
-│ │
-│ ⚠️ Expiring Soon │
-│ ┌────────────────────────────────────┐ │
-│ │ alice on Expenses:Food (3 days) │ │
-│ │ bob on Income:Services (5 days) │ │
-│ └────────────────────────────────────┘ │
-│ │
-│ Top Accounts by Permissions │
-│ ┌────────────────────────────────────┐ │
-│ │ 1. Expenses:Food (25 permissions) │ │
-│ │ 2. Expenses:Accommodation (18) │ │
-│ │ 3. Income:Services (12) │ │
-│ └────────────────────────────────────┘ │
-└─────────────────────────────────────────────┘
-```
-
-**Implementation:**
-- New API endpoint: `GET /api/v1/admin/permissions/analytics`
-- Client-side stats display with Quasar charts
-- Auto-refresh every 30 seconds
-
----
-
-### 2. Bulk Permission Operations Menu
-
-**Purpose**: Enable admins to perform bulk operations efficiently
-
-**Features:**
-- Bulk Grant (multiple users)
-- Copy Permissions (template from user)
-- Offboard User (revoke all)
-- Close Account (revoke all on account)
-
-**UI Mockup:**
-```
-┌─────────────────────────────────────────────┐
-│ 🔐 Permission Management │
-│ │
-│ [Grant Permission ▼] [Bulk Operations ▼] │
-│ │
-│ ┌──────────────────────────────────┐ │
-│ │ • Bulk Grant to Multiple Users │ │
-│ │ • Copy Permissions from User │ │
-│ │ • Offboard User (Revoke All) │ │
-│ │ • Close Account (Revoke All) │ │
-│ │ • Sync Accounts from Beancount │ │
-│ └──────────────────────────────────┘ │
-└─────────────────────────────────────────────┘
-```
-
----
-
-### 3. Bulk Grant Dialog
-
-**UI Mockup:**
-```
-┌───────────────────────────────────────────┐
-│ 👥 Bulk Grant Permission │
-├───────────────────────────────────────────┤
-│ │
-│ Select Users * │
-│ ┌────────────────────────────────────┐ │
-│ │ 🔍 Search users... │ │
-│ └────────────────────────────────────┘ │
-│ │
-│ Selected: alice, bob, charlie (3 users) │
-│ │
-│ Select Account * │
-│ ┌────────────────────────────────────┐ │
-│ │ Expenses:Food │ │
-│ └────────────────────────────────────┘ │
-│ │
-│ Permission Type * │
-│ ┌────────────────────────────────────┐ │
-│ │ Submit Expense │ │
-│ └────────────────────────────────────┘ │
-│ │
-│ Expires (Optional) │
-│ [2025-12-31 23:59:59] │
-│ │
-│ Notes (Optional) │
-│ [Q4 2025 food team members] │
-│ │
-│ ℹ️ This will grant SUBMIT_EXPENSE │
-│ permission to 3 users on │
-│ Expenses:Food │
-│ │
-│ [Cancel] [Grant to 3 Users] │
-└───────────────────────────────────────────┘
-```
-
-**Features:**
-- Multi-select user dropdown
-- Preview of operation before confirm
-- Shows estimated time savings
-
----
-
-### 4. Copy Permissions Dialog
-
-**UI Mockup:**
-```
-┌───────────────────────────────────────────┐
-│ 📋 Copy Permissions │
-├───────────────────────────────────────────┤
-│ │
-│ Copy From (Template User) * │
-│ ┌────────────────────────────────────┐ │
-│ │ alice (Experienced Coordinator) │ │
-│ └────────────────────────────────────┘ │
-│ │
-│ 📊 alice has 5 permissions: │
-│ • Expenses:Food (Submit Expense) │
-│ • Expenses:Food:Groceries (Submit) │
-│ • Income:Services (Read) │
-│ • Assets:Cash (Read) │
-│ • Expenses:Utilities (Submit) │
-│ │
-│ Copy To (New User) * │
-│ ┌────────────────────────────────────┐ │
-│ │ bob (New Hire) │ │
-│ └────────────────────────────────────┘ │
-│ │
-│ Filter by Permission Type (Optional) │
-│ ☑ Submit Expense ☐ Read ☐ Manage │
-│ │
-│ Notes │
-│ [Copied from Alice - new coordinator] │
-│ │
-│ ℹ️ This will copy 3 SUBMIT_EXPENSE │
-│ permissions from alice to bob │
-│ │
-│ [Cancel] [Copy Permissions] │
-└───────────────────────────────────────────┘
-```
-
-**Features:**
-- Shows source user's permissions
-- Filter by permission type
-- Preview before copying
-
----
-
-### 5. Offboard User Dialog
-
-**UI Mockup:**
-```
-┌───────────────────────────────────────────┐
-│ 🚪 Offboard User │
-├───────────────────────────────────────────┤
-│ │
-│ Select User to Offboard * │
-│ ┌────────────────────────────────────┐ │
-│ │ charlie (Departed Employee) │ │
-│ └────────────────────────────────────┘ │
-│ │
-│ ⚠️ Current Permissions (8 total): │
-│ ┌────────────────────────────────────┐ │
-│ │ • Expenses:Food (Submit Expense) │ │
-│ │ • Expenses:Utilities (Submit) │ │
-│ │ • Income:Services (Read) │ │
-│ │ • Assets:Cash (Read) │ │
-│ │ • Expenses:Accommodation (Submit) │ │
-│ │ • ... 3 more │ │
-│ └────────────────────────────────────┘ │
-│ │
-│ ⚠️ Warning: This will revoke ALL │
-│ permissions for this user. They will │
-│ immediately lose access to Castle. │
-│ │
-│ Reason for Offboarding │
-│ [Employee departure - last day] │
-│ │
-│ [Cancel] [Revoke All (8)] │
-└───────────────────────────────────────────┘
-```
-
-**Features:**
-- Shows all current permissions
-- Requires confirmation
-- Logs reason for audit
-
----
-
-### 6. Account Sync UI
-
-**Location**: Admin Settings or Bulk Operations menu
-
-**UI Mockup:**
-```
-┌───────────────────────────────────────────┐
-│ 🔄 Sync Accounts from Beancount │
-├───────────────────────────────────────────┤
-│ │
-│ Sync accounts from your Beancount ledger │
-│ to Castle database for permission mgmt. │
-│ │
-│ Last Sync: 2 hours ago │
-│ Status: ✅ Up to date │
-│ │
-│ Accounts in Beancount: 150 │
-│ Accounts in Castle DB: 150 │
-│ │
-│ Options: │
-│ ☐ Force full sync (re-check all) │
-│ │
-│ [Sync Now] │
-│ │
-│ Recent Sync History: │
-│ ┌────────────────────────────────────┐ │
-│ │ Nov 10, 2:00 PM - Added 2 accounts │ │
-│ │ Nov 10, 12:00 PM - Up to date │ │
-│ │ Nov 10, 10:00 AM - Added 1 account │ │
-│ └────────────────────────────────────┘ │
-└───────────────────────────────────────────┘
-```
-
-**Features:**
-- Shows sync status
-- Last sync timestamp
-- Account counts
-- Sync history
-
----
-
-### 7. Expiring Permissions Alert
-
-**Location**: Top of permissions page (if any expiring soon)
-
-**UI Mockup:**
-```
-┌─────────────────────────────────────────────┐
-│ ⚠️ 5 Permissions Expiring Soon (Next 7 Days)│
-├─────────────────────────────────────────────┤
-│ • alice on Expenses:Food (3 days) │
-│ • bob on Income:Services (5 days) │
-│ • charlie on Assets:Cash (7 days) │
-│ │
-│ [View All] [Extend Expiration] [Dismiss] │
-└─────────────────────────────────────────────┘
-```
-
-**Features:**
-- Prominent alert banner
-- Shows expiring in next 7 days
-- Quick actions to extend
-
----
-
-### 8. Permission Templates (Future)
-
-**Concept**: Pre-defined permission sets for common roles
-
-**UI Mockup:**
-```
-┌───────────────────────────────────────────┐
-│ 📝 Apply Permission Template │
-├───────────────────────────────────────────┤
-│ │
-│ Select User * │
-│ [bob] │
-│ │
-│ Select Template * │
-│ ┌────────────────────────────────────┐ │
-│ │ Food Coordinator (5 permissions) │ │
-│ │ • Expenses:Food (Submit) │ │
-│ │ • Expenses:Food:* (Submit) │ │
-│ │ • Income:Services (Read) │ │
-│ │ │ │
-│ │ Accommodation Manager (8 perms) │ │
-│ │ Finance Admin (15 perms) │ │
-│ │ Read-Only Auditor (25 perms) │ │
-│ └────────────────────────────────────┘ │
-│ │
-│ [Cancel] [Apply Template] │
-└───────────────────────────────────────────┘
-```
-
----
-
-## Implementation Priority
-
-### Phase 1: Quick Wins (This Week)
-**Effort**: 2-3 days
-
-1. **Analytics Tab** (1 day)
- - Add new tab to permissions.html
- - Call analytics API endpoint
- - Display stats with Quasar components
-
-2. **Bulk Grant Dialog** (1 day)
- - Add multi-select user dropdown
- - Call bulk grant API
- - Show success/failure results
-
-3. **Account Sync Button** (0.5 days)
- - Add sync button to admin area
- - Call sync API
- - Show progress and results
-
-**Impact**: Immediate productivity boost for admins
-
----
-
-### Phase 2: Bulk Operations (Next Week)
-**Effort**: 2-3 days
-
-4. **Copy Permissions Dialog** (1 day)
- - Template selection UI
- - Preview permissions
- - Copy operation
-
-5. **Offboard User Dialog** (1 day)
- - User selection with permission preview
- - Confirmation with reason logging
- - Bulk revoke operation
-
-6. **Expiring Permissions Alert** (0.5 days)
- - Alert banner component
- - Query expiring permissions
- - Quick actions
-
-**Impact**: Major time savings for common workflows
-
----
-
-### Phase 3: Polish (Later)
-**Effort**: 2-3 days
-
-7. **Permission Templates** (2 days)
- - Template management UI
- - Template CRUD operations
- - Apply template workflow
-
-8. **Advanced Analytics** (1 day)
- - Charts and graphs
- - Permission history timeline
- - Usage patterns
-
-**Impact**: Long-term ease of use
-
----
-
-## Technical Implementation
-
-### New API Endpoints Needed
-
-```javascript
-// Analytics
-GET /api/v1/admin/permissions/analytics
-
-// Bulk Operations
-POST /api/v1/admin/permissions/bulk-grant
-{
- user_ids: ["alice", "bob", "charlie"],
- account_id: "acc123",
- permission_type: "submit_expense",
- expires_at: "2025-12-31T23:59:59",
- notes: "Q4 team"
-}
-
-POST /api/v1/admin/permissions/copy
-{
- from_user_id: "alice",
- to_user_id: "bob",
- permission_types: ["submit_expense"],
- notes: "New coordinator"
-}
-
-DELETE /api/v1/admin/permissions/user/{user_id}
-
-DELETE /api/v1/admin/permissions/account/{account_id}
-
-// Account Sync
-POST /api/v1/admin/sync-accounts
-{
- force_full_sync: false
-}
-
-GET /api/v1/admin/sync-accounts/status
-```
-
-### Vue.js Component Structure
-
-```
-permissions.html
-├── Analytics Tab (new)
-│ ├── Stats Cards
-│ ├── Distribution Chart
-│ ├── Expiring Soon List
-│ └── Top Accounts List
-│
-├── By User Tab (existing)
-│ └── Enhanced with bulk operations
-│
-├── By Account Tab (existing)
-│ └── Enhanced with bulk operations
-│
-├── Equity Tab (existing)
-│
-└── Dialogs
- ├── Bulk Grant Dialog (new)
- ├── Copy Permissions Dialog (new)
- ├── Offboard User Dialog (new)
- ├── Account Sync Dialog (new)
- ├── Grant Permission Dialog (existing)
- └── Revoke Confirmation Dialog (existing)
-```
-
-### State Management
-
-```javascript
-// Add to Vue app data
-{
- // Analytics
- analytics: {
- total: 0,
- byType: {},
- expiringSoon: [],
- topAccounts: []
- },
-
- // Bulk Operations
- bulkGrantForm: {
- user_ids: [],
- account_id: null,
- permission_type: null,
- expires_at: null,
- notes: ''
- },
-
- copyPermissionsForm: {
- from_user_id: null,
- to_user_id: null,
- permission_types: [],
- notes: ''
- },
-
- offboardForm: {
- user_id: null,
- reason: ''
- },
-
- // Account Sync
- syncStatus: {
- lastSync: null,
- beancountAccounts: 0,
- castleAccounts: 0,
- status: 'idle'
- }
-}
-```
-
----
-
-## User Experience Flow
-
-### Onboarding New Team Member (Before vs After)
-
-**Before** (10 minutes):
-1. Open permissions page
-2. Click "Grant Permission" 5 times
-3. Fill form each time (user, account, type)
-4. Click grant, repeat
-5. Hope you didn't forget any
-
-**After** (1 minute):
-1. Click "Bulk Operations" → "Copy Permissions"
-2. Select template user → Select new user
-3. Click "Copy"
-4. Done! ✨
-
-**Time Saved**: 90%
-
----
-
-### Quarterly Access Review (Before vs After)
-
-**Before** (2 hours):
-1. Export permissions to spreadsheet
-2. Manually review each one
-3. Delete expired individually
-4. Update expiration dates one by one
-
-**After** (5 minutes):
-1. Click "Analytics" tab
-2. See "5 Expiring Soon" alert
-3. Review list, click "Extend" on relevant ones
-4. Done! ✨
-
-**Time Saved**: 96%
-
----
-
-## Testing Plan
-
-### UI Testing
-
-```javascript
-// Test bulk grant
-async function testBulkGrant() {
- // 1. Open bulk grant dialog
- // 2. Select 3 users
- // 3. Select account
- // 4. Select permission type
- // 5. Click grant
- // 6. Verify success message
- // 7. Verify permissions appear in UI
-}
-
-// Test copy permissions
-async function testCopyPermissions() {
- // 1. Open copy dialog
- // 2. Select source user with 5 permissions
- // 3. Select target user
- // 4. Filter to SUBMIT_EXPENSE only
- // 5. Verify preview shows 3 permissions
- // 6. Click copy
- // 7. Verify target user has 3 new permissions
-}
-
-// Test analytics
-async function testAnalytics() {
- // 1. Switch to analytics tab
- // 2. Verify stats load
- // 3. Verify charts display
- // 4. Verify expiring permissions show
- // 5. Click on expiring permission
- // 6. Verify details dialog opens
-}
-```
-
-### Integration Testing
-
-```python
-# Test full workflow
-async def test_onboarding_workflow():
- # 1. Admin syncs accounts
- sync_result = await api.post("/admin/sync-accounts")
- assert sync_result["accounts_added"] >= 0
-
- # 2. Admin copies permissions from template user
- copy_result = await api.post("/admin/permissions/copy", {
- "from_user_id": "template",
- "to_user_id": "new_user"
- })
- assert copy_result["copied"] > 0
-
- # 3. Verify new user has permissions in UI
- perms = await api.get(f"/admin/permissions?user_id=new_user")
- assert len(perms) > 0
-
- # 4. Check analytics reflect new permissions
- analytics = await api.get("/admin/permissions/analytics")
- assert analytics["total_permissions"] increased
-```
-
----
-
-## Accessibility
-
-- ✅ Keyboard navigation support
-- ✅ Screen reader friendly labels
-- ✅ Color contrast compliance (WCAG AA)
-- ✅ Focus indicators
-- ✅ ARIA labels on interactive elements
-
----
-
-## Mobile Responsiveness
-
-- ✅ Analytics cards stack vertically on mobile
-- ✅ Dialogs are full-screen on small devices
-- ✅ Touch-friendly button sizes
-- ✅ Swipe gestures for tabs
-
----
-
-## Error Handling
-
-**Bulk Grant Fails:**
-```
-⚠️ Bulk Grant Results
-✅ Granted to 3 users
-❌ Failed for 2 users:
- • bob: Already has permission
- • charlie: Account not found
-
-[View Details] [Retry Failed] [Dismiss]
-```
-
-**Account Sync Fails:**
-```
-❌ Account Sync Failed
-Could not connect to Beancount service.
-
-Error: Connection timeout after 10s
-
-[Retry] [Check Settings] [Dismiss]
-```
-
----
-
-## Performance Considerations
-
-- **Pagination**: Load permissions in batches of 50
-- **Lazy Loading**: Load analytics only when tab is viewed
-- **Debouncing**: Debounce search inputs (300ms)
-- **Caching**: Cache analytics for 30 seconds
-- **Optimistic UI**: Show loading state immediately
-
----
-
-## Security Considerations
-
-- ✅ All bulk operations require admin key
-- ✅ Confirmation dialogs for destructive actions
-- ✅ Audit log all bulk operations
-- ✅ Rate limiting on API endpoints
-- ✅ CSRF protection on forms
-
----
-
-## Documentation
-
-**User Guide** (to create):
-1. How to bulk grant permissions
-2. How to copy permissions (templating)
-3. How to offboard a user
-4. How to sync accounts
-5. How to use analytics dashboard
-
-**Admin Guide** (to create):
-1. When to use bulk operations
-2. Best practices for permission templates
-3. How to monitor permission usage
-4. Troubleshooting sync issues
-
----
-
-## Success Metrics
-
-**Measure after deployment:**
-- Time to onboard new user: 10min → 1min
-- Time for access review: 2hr → 5min
-- Admin satisfaction score: 6/10 → 9/10
-- Support tickets for permissions: -70%
-- Permissions granted per month: +40%
-
----
-
-## Summary
-
-This UI improvement plan focuses on:
-
-1. **Quick Wins**: Analytics and bulk grant (2-3 days)
-2. **Bulk Operations**: Copy, offboard, sync (2-3 days)
-3. **Polish**: Templates and advanced features (later)
-
-**Total Time**: ~5-6 days for Phase 1 & 2
-**Impact**: 50-70% reduction in admin time
-**ROI**: Immediate productivity boost
-
-The enhancements leverage the new backend features we built (account sync, bulk permission management) and make them accessible through an intuitive UI, significantly improving the admin experience.
-
----
-
-**Document Version**: 1.0
-**Last Updated**: November 10, 2025
-**Status**: Ready for Implementation
diff --git a/fava_client.py b/fava_client.py
deleted file mode 100644
index 4cde16b..0000000
--- a/fava_client.py
+++ /dev/null
@@ -1,1231 +0,0 @@
-"""
-Fava API client for Castle.
-
-This module provides an async HTTP client for interacting with Fava's JSON API.
-All accounting logic is delegated to Fava/Beancount.
-
-Fava provides a REST API for:
-- Adding transactions (PUT /api/add_entries)
-- Adding accounts via Open directives (PUT /api/add_entries)
-- Querying balances (GET /api/query)
-- Balance sheets (GET /api/balance_sheet)
-- Account reports (GET /api/account_report)
-- Updating/deleting entries (PUT/DELETE /api/source_slice)
-
-See: https://github.com/beancount/fava/blob/main/src/fava/json_api.py
-"""
-
-import httpx
-from typing import Any, Dict, List, Optional
-from decimal import Decimal
-from datetime import date, datetime
-from loguru import logger
-
-
-class FavaClient:
- """
- Async client for Fava REST API.
-
- Fava runs as a separate web service and provides a JSON API
- for adding entries and querying ledger data.
-
- All accounting calculations are performed by Beancount via Fava.
- """
-
- def __init__(self, fava_url: str, ledger_slug: str, timeout: float = 10.0):
- """
- Initialize Fava client.
-
- Args:
- fava_url: Base URL of Fava server (e.g., http://localhost:3333)
- ledger_slug: URL-safe ledger identifier (e.g., castle-accounting)
- timeout: Request timeout in seconds
- """
- self.fava_url = fava_url.rstrip('/')
- self.ledger_slug = ledger_slug
- self.base_url = f"{self.fava_url}/{self.ledger_slug}/api"
- self.timeout = timeout
-
- async def add_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
- """
- Submit a new journal entry to Fava.
-
- Args:
- entry: Beancount entry dict (format per Fava API spec)
- Must include:
- - t: "Transaction" (required by Fava)
- - date: "YYYY-MM-DD"
- - flag: "*" (cleared) or "!" (pending)
- - narration: str
- - postings: list of posting dicts
- - payee: str (empty string, not None)
- - tags: list of str
- - links: list of str
- - meta: dict
-
- Returns:
- Response from Fava ({"data": "Stored 1 entries.", "mtime": "..."})
-
- Raises:
- httpx.HTTPStatusError: If Fava returns an error
- httpx.RequestError: If connection fails
-
- Example:
- entry = {
- "t": "Transaction",
- "date": "2025-01-15",
- "flag": "*",
- "payee": "Store",
- "narration": "Purchase",
- "postings": [
- {"account": "Expenses:Food", "amount": "50.00 EUR"},
- {"account": "Assets:Cash", "amount": "-50.00 EUR"}
- ],
- "tags": [],
- "links": [],
- "meta": {"user_id": "abc123"}
- }
- result = await fava_client.add_entry(entry)
- """
- try:
- async with httpx.AsyncClient(timeout=self.timeout) as client:
- response = await client.put(
- f"{self.base_url}/add_entries",
- json={"entries": [entry]},
- headers={"Content-Type": "application/json"}
- )
- response.raise_for_status()
- result = response.json()
-
- logger.info(f"Added entry to Fava: {result.get('data', 'Unknown')}")
- return result
-
- except httpx.HTTPStatusError as e:
- logger.error(f"Fava HTTP error: {e.response.status_code} - {e.response.text}")
- raise
- except httpx.RequestError as e:
- logger.error(f"Fava connection error: {e}")
- raise
-
- async def get_account_balance(self, account_name: str) -> Dict[str, Any]:
- """
- Get balance for a specific account (excluding pending transactions).
-
- Args:
- account_name: Full account name (e.g., "Assets:Receivable:User-abc123")
-
- Returns:
- Dict with:
- - sats: int (balance in satoshis)
- - positions: dict (currency → amount with cost basis)
-
- Note:
- Excludes pending transactions (flag='!') from balance calculation.
- Only cleared/completed transactions (flag='*') are included.
-
- Example:
- balance = await fava_client.get_account_balance("Assets:Receivable:User-abc")
- # Returns: {
- # "sats": 200000,
- # "positions": {"SATS": {"{100.00 EUR}": 200000}}
- # }
- """
- query = f"SELECT sum(position) WHERE account = '{account_name}' AND flag != '!'"
-
- try:
- async with httpx.AsyncClient(timeout=self.timeout) as client:
- response = await client.get(
- f"{self.base_url}/query",
- params={"query_string": query}
- )
- response.raise_for_status()
- data = response.json()
-
- if not data['data']['rows']:
- return {"sats": 0, "positions": {}}
-
- # Fava returns: [[account, {"SATS": {cost: amount}}]]
- positions = data['data']['rows'][0][1] if data['data']['rows'] else {}
-
- # Sum up all SATS positions
- total_sats = 0
- if isinstance(positions, dict) and "SATS" in positions:
- sats_positions = positions["SATS"]
- if isinstance(sats_positions, dict):
- # Sum all amounts (with different cost bases)
- total_sats = sum(int(amount) for amount in sats_positions.values())
- elif isinstance(sats_positions, (int, float)):
- # Simple number (no cost basis)
- total_sats = int(sats_positions)
-
- return {
- "sats": total_sats,
- "positions": positions
- }
-
- except httpx.HTTPStatusError as e:
- logger.error(f"Fava query error: {e.response.status_code} - {e.response.text}")
- raise
- except httpx.RequestError as e:
- logger.error(f"Fava connection error: {e}")
- raise
-
- async def get_user_balance(self, user_id: str) -> Dict[str, Any]:
- """
- Get user's balance from castle's perspective.
-
- Aggregates:
- - Liabilities:Payable:User-{user_id} (negative = castle owes user)
- - Assets:Receivable:User-{user_id} (positive = user owes castle)
-
- Args:
- user_id: User ID
-
- Returns:
- {
- "balance": int (sats, positive = user owes castle, negative = castle owes user),
- "fiat_balances": {"EUR": Decimal("100.50")},
- "accounts": [list of account dicts with balances]
- }
-
- Note:
- Excludes pending transactions (flag='!') from balance calculation.
- Only cleared/completed transactions (flag='*') are included.
- """
- # Get all journal entries for this user
- all_entries = await self.get_journal_entries()
-
- total_sats = 0
- fiat_balances = {}
- accounts_dict = {} # Track balances per account
-
- for entry in all_entries:
- # Skip non-transactions, pending (!), and voided
- if entry.get("t") != "Transaction":
- continue
- if entry.get("flag") == "!":
- continue
- if "voided" in entry.get("tags", []):
- continue
-
- # Process postings for this user
- for posting in entry.get("postings", []):
- account_name = posting.get("account", "")
-
- # Only process this user's accounts (account names use first 8 chars of user_id)
- if f":User-{user_id[:8]}" not in account_name:
- continue
- if "Payable" not in account_name and "Receivable" not in account_name:
- continue
-
- # Parse amount string: can be EUR, USD, or SATS
- amount_str = posting.get("amount", "")
- if not isinstance(amount_str, str) or not amount_str:
- continue
-
- import re
- # Try to extract EUR/USD amount first (new format)
- fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
- if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
- # Direct EUR/USD amount (new approach)
- fiat_amount = Decimal(fiat_match.group(1))
- fiat_currency = fiat_match.group(2)
-
- if fiat_currency not in fiat_balances:
- fiat_balances[fiat_currency] = Decimal(0)
-
- fiat_balances[fiat_currency] += fiat_amount
-
- # Also track SATS equivalent from metadata if available
- posting_meta = posting.get("meta", {})
- sats_equiv = posting_meta.get("sats-equivalent")
- if sats_equiv:
- sats_amount = int(sats_equiv) if fiat_amount > 0 else -int(sats_equiv)
- total_sats += sats_amount
- if account_name not in accounts_dict:
- accounts_dict[account_name] = {"account": account_name, "sats": 0}
- accounts_dict[account_name]["sats"] += sats_amount
-
- else:
- # Old format: SATS with cost/price notation - extract SATS amount
- sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
- if sats_match:
- sats_amount = int(sats_match.group(1))
- total_sats += sats_amount
-
- # Track per account
- if account_name not in accounts_dict:
- accounts_dict[account_name] = {"account": account_name, "sats": 0}
- accounts_dict[account_name]["sats"] += sats_amount
-
- # Try to extract fiat from metadata or cost syntax (backward compatibility)
- posting_meta = posting.get("meta", {})
- fiat_amount_total_str = posting_meta.get("fiat-amount-total")
- fiat_currency_meta = posting_meta.get("fiat-currency")
-
- if fiat_amount_total_str and fiat_currency_meta:
- # Use exact total from metadata
- fiat_total = Decimal(fiat_amount_total_str)
- fiat_currency = fiat_currency_meta
-
- if fiat_currency not in fiat_balances:
- fiat_balances[fiat_currency] = Decimal(0)
-
- # Apply the same sign as the SATS amount
- if sats_match:
- sats_amount_for_sign = int(sats_match.group(1))
- if sats_amount_for_sign < 0:
- fiat_total = -fiat_total
-
- fiat_balances[fiat_currency] += fiat_total
-
- logger.info(f"User {user_id[:8]} balance: {total_sats} sats, fiat: {dict(fiat_balances)}")
- return {
- "balance": total_sats,
- "fiat_balances": fiat_balances,
- "accounts": list(accounts_dict.values())
- }
-
- async def get_all_user_balances(self) -> List[Dict[str, Any]]:
- """
- Get balances for all users (admin view).
-
- Returns:
- [
- {
- "user_id": "abc123",
- "balance": 100000,
- "fiat_balances": {"EUR": Decimal("100.50")},
- "accounts": [...]
- },
- ...
- ]
-
- Note:
- Excludes pending transactions (flag='!') and voided (tag #voided) from balance calculation.
- Only cleared/completed transactions (flag='*') are included.
- """
- # Get all journal entries and calculate balances from postings
- all_entries = await self.get_journal_entries()
-
- # Group by user_id
- user_data = {}
-
- for entry in all_entries:
- # Skip non-transactions, pending (!), and voided
- if entry.get("t") != "Transaction":
- continue
- if entry.get("flag") == "!":
- continue
- if "voided" in entry.get("tags", []):
- continue
-
- # Process postings
- for posting in entry.get("postings", []):
- account_name = posting.get("account", "")
-
- # Only process user accounts (Payable or Receivable)
- if ":User-" not in account_name:
- continue
- if "Payable" not in account_name and "Receivable" not in account_name:
- continue
-
- # Extract user_id from account name
- user_id = account_name.split(":User-")[1]
-
- if user_id not in user_data:
- user_data[user_id] = {
- "user_id": user_id,
- "balance": 0,
- "fiat_balances": {},
- "accounts": []
- }
-
- # Parse amount string: can be EUR/USD directly (new format) or "SATS {EUR}" (old format)
- amount_str = posting.get("amount", "")
- if not isinstance(amount_str, str) or not amount_str:
- continue
-
- import re
- # Try to extract EUR/USD amount first (new format)
- fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str)
- if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
- # Direct EUR/USD amount (new approach)
- fiat_amount = Decimal(fiat_match.group(1))
- fiat_currency = fiat_match.group(2)
-
- if fiat_currency not in user_data[user_id]["fiat_balances"]:
- user_data[user_id]["fiat_balances"][fiat_currency] = Decimal(0)
-
- user_data[user_id]["fiat_balances"][fiat_currency] += fiat_amount
-
- # Also track SATS equivalent from metadata if available
- posting_meta = posting.get("meta", {})
- sats_equiv = posting_meta.get("sats-equivalent")
- if sats_equiv:
- sats_amount = int(sats_equiv) if fiat_amount > 0 else -int(sats_equiv)
- user_data[user_id]["balance"] += sats_amount
-
- else:
- # Old format: SATS with cost/price notation
- sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
- if sats_match:
- sats_amount = int(sats_match.group(1))
- user_data[user_id]["balance"] += sats_amount
-
- # Extract fiat from cost syntax or metadata (backward compatibility)
- posting_meta = posting.get("meta", {})
- fiat_amount_total_str = posting_meta.get("fiat-amount-total")
- fiat_currency_meta = posting_meta.get("fiat-currency")
-
- if fiat_amount_total_str and fiat_currency_meta:
- fiat_total = Decimal(fiat_amount_total_str)
- fiat_currency = fiat_currency_meta
-
- if fiat_currency not in user_data[user_id]["fiat_balances"]:
- user_data[user_id]["fiat_balances"][fiat_currency] = Decimal(0)
-
- # Apply the same sign as the SATS amount
- if sats_match:
- sats_amount_for_sign = int(sats_match.group(1))
- if sats_amount_for_sign < 0:
- fiat_total = -fiat_total
-
- user_data[user_id]["fiat_balances"][fiat_currency] += fiat_total
-
- return list(user_data.values())
-
- async def check_fava_health(self) -> bool:
- """
- Check if Fava is running and accessible.
-
- Returns:
- True if Fava responds, False otherwise
- """
- try:
- async with httpx.AsyncClient(timeout=2.0) as client:
- response = await client.get(
- f"{self.base_url}/changed"
- )
- return response.status_code == 200
- except Exception as e:
- logger.warning(f"Fava health check failed: {e}")
- return False
-
- async def query_transactions(
- self,
- account_pattern: Optional[str] = None,
- limit: int = 100,
- include_pending: bool = True
- ) -> List[Dict[str, Any]]:
- """
- Query transactions from Fava/Beancount.
-
- Args:
- account_pattern: Optional regex pattern to filter accounts (e.g., "User-abc123")
- limit: Maximum number of transactions to return
- include_pending: Include pending transactions (flag='!')
-
- Returns:
- List of transaction dictionaries with date, description, postings, etc.
-
- Example:
- # All transactions
- txns = await fava.query_transactions()
-
- # User's transactions
- txns = await fava.query_transactions(account_pattern="User-abc123")
-
- # Account transactions
- txns = await fava.query_transactions(account_pattern="Assets:Receivable:User-abc")
- """
- # Build Beancount query
- if account_pattern:
- query = f"SELECT * WHERE account ~ '{account_pattern}' ORDER BY date DESC LIMIT {limit}"
- else:
- query = f"SELECT * ORDER BY date DESC LIMIT {limit}"
-
- try:
- async with httpx.AsyncClient(timeout=self.timeout) as client:
- response = await client.get(
- f"{self.base_url}/query",
- params={"query_string": query}
- )
- response.raise_for_status()
- result = response.json()
-
- # Fava query API returns: {"data": {"rows": [...], "types": [...]}}
- data = result.get("data", {})
- rows = data.get("rows", [])
- types = data.get("types", [])
-
- # Build column name mapping
- column_names = [t.get("name") for t in types]
-
- # Transform Fava's query result to transaction list
- transactions = []
- for row in rows:
- # Rows are arrays, convert to dict using column names
- if isinstance(row, list) and len(row) == len(column_names):
- txn = dict(zip(column_names, row))
-
- # Filter by flag if needed
- flag = txn.get("flag", "*")
- if not include_pending and flag == "!":
- continue
-
- transactions.append(txn)
- elif isinstance(row, dict):
- # Already a dict (shouldn't happen with BQL, but handle it)
- flag = row.get("flag", "*")
- if not include_pending and flag == "!":
- continue
- transactions.append(row)
-
- return transactions[:limit]
-
- except httpx.HTTPStatusError as e:
- logger.error(f"Fava query error: {e.response.status_code} - {e.response.text}")
- raise
- except httpx.RequestError as e:
- logger.error(f"Fava connection error: {e}")
- raise
-
- async def query_bql(self, query_string: str) -> Dict[str, Any]:
- """
- Execute arbitrary Beancount Query Language (BQL) query.
-
- This is a general-purpose method for executing BQL queries against Fava/Beancount.
- Use this for efficient aggregations, filtering, and data retrieval.
-
- ⚠️ LIMITATION: BQL can only query position amounts and transaction-level data.
- It CANNOT access posting metadata (like 'sats-equivalent'). For Castle's current
- ledger format where SATS are stored in metadata, manual aggregation is required.
-
- See: docs/BQL-BALANCE-QUERIES.md for detailed analysis and test results.
-
- FUTURE CONSIDERATION: If Castle's ledger format changes to use SATS as position
- amounts (instead of metadata), BQL could provide significant performance benefits.
-
- Args:
- query_string: BQL query (e.g., "SELECT account, sum(position) WHERE account ~ 'User-abc'")
-
- Returns:
- {
- "rows": [[col1, col2, ...], ...],
- "types": [{"name": "col1", "type": "str"}, ...],
- "column_names": ["col1", "col2", ...]
- }
-
- Example:
- result = await fava.query_bql("SELECT account, sum(position) WHERE account ~ 'User-abc'")
- for row in result["rows"]:
- account, balance = row
- print(f"{account}: {balance}")
-
- See:
- https://beancount.github.io/docs/beancount_query_language.html
- """
- try:
- async with httpx.AsyncClient(timeout=self.timeout) as client:
- response = await client.get(
- f"{self.base_url}/query",
- params={"query_string": query_string}
- )
- response.raise_for_status()
- result = response.json()
-
- # Fava returns: {"data": {"rows": [...], "types": [...]}}
- data = result.get("data", {})
- rows = data.get("rows", [])
- types = data.get("types", [])
- column_names = [t.get("name") for t in types]
-
- return {
- "rows": rows,
- "types": types,
- "column_names": column_names
- }
-
- except httpx.HTTPStatusError as e:
- logger.error(f"BQL query error: {e.response.status_code} - {e.response.text}")
- logger.error(f"Query was: {query_string}")
- raise
- except httpx.RequestError as e:
- logger.error(f"Fava connection error: {e}")
- raise
-
- async def get_user_balance_bql(self, user_id: str) -> Dict[str, Any]:
- """
- Get user balance using BQL (efficient, replaces 115-line manual aggregation).
-
- ⚠️ NOT CURRENTLY USED: This method cannot access SATS balances stored in posting
- metadata. It only queries position amounts (EUR/USD). For Castle's current ledger
- format, use get_user_balance() instead (manual aggregation with caching).
-
- This method uses Beancount Query Language for server-side filtering and aggregation,
- which would provide 5-10x performance improvement IF SATS were stored as position
- amounts instead of metadata.
-
- FUTURE CONSIDERATION: If Castle's ledger format changes to store SATS as position
- amounts (e.g., "100000 SATS {100.00 EUR}"), this method would become feasible and
- provide significant performance benefits.
-
- See: docs/BQL-BALANCE-QUERIES.md for detailed test results and analysis.
-
- Args:
- user_id: User ID
-
- Returns:
- {
- "balance": int (sats), # Currently returns 0 (cannot access metadata)
- "fiat_balances": {"EUR": Decimal("100.50"), ...}, # Works correctly
- "accounts": [{"account": "...", "sats": 150000}, ...]
- }
-
- Example:
- balance = await fava.get_user_balance_bql("af983632")
- print(f"Balance: {balance['balance']} sats") # Will be 0 with current ledger format
- """
- from decimal import Decimal
- import re
-
- # Build BQL query for this user's Payable/Receivable accounts
- user_id_prefix = user_id[:8]
- query = f"""
- SELECT account, sum(position) as balance
- WHERE account ~ ':User-{user_id_prefix}'
- AND (account ~ 'Payable' OR account ~ 'Receivable')
- AND flag != '!'
- GROUP BY account
- """
-
- result = await self.query_bql(query)
-
- # Process results
- total_sats = 0
- fiat_balances = {}
- accounts = []
-
- for row in result["rows"]:
- account_name, position = row
-
- # Position can be:
- # - Dict: {"SATS": "150000", "EUR": "145.50"}
- # - String: "150000 SATS" or "145.50 EUR"
-
- if isinstance(position, dict):
- # Extract SATS
- sats_str = position.get("SATS", "0")
- sats_amount = int(sats_str) if sats_str else 0
- total_sats += sats_amount
-
- accounts.append({
- "account": account_name,
- "sats": sats_amount
- })
-
- # Extract fiat currencies
- for currency in ["EUR", "USD", "GBP"]:
- if currency in position:
- fiat_str = position[currency]
- fiat_amount = Decimal(fiat_str) if fiat_str else Decimal(0)
-
- if currency not in fiat_balances:
- fiat_balances[currency] = Decimal(0)
- fiat_balances[currency] += fiat_amount
-
- elif isinstance(position, str):
- # Single currency (parse "150000 SATS" or "145.50 EUR")
- sats_match = re.match(r'^(-?\d+)\s+SATS$', position)
- if sats_match:
- sats_amount = int(sats_match.group(1))
- total_sats += sats_amount
- accounts.append({
- "account": account_name,
- "sats": sats_amount
- })
- else:
- fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', position)
- if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
- fiat_amount = Decimal(fiat_match.group(1))
- currency = fiat_match.group(2)
-
- if currency not in fiat_balances:
- fiat_balances[currency] = Decimal(0)
- fiat_balances[currency] += fiat_amount
-
- logger.info(f"User {user_id[:8]} balance (BQL): {total_sats} sats, fiat: {dict(fiat_balances)}")
-
- return {
- "balance": total_sats,
- "fiat_balances": fiat_balances,
- "accounts": accounts
- }
-
- async def get_all_user_balances_bql(self) -> List[Dict[str, Any]]:
- """
- Get balances for all users using BQL (efficient admin view).
-
- ⚠️ NOT CURRENTLY USED: This method cannot access SATS balances stored in posting
- metadata. It only queries position amounts (EUR/USD). For Castle's current ledger
- format, use get_all_user_balances() instead (manual aggregation with caching).
-
- This method uses Beancount Query Language to query all user balances
- in a single efficient query, which would be faster than fetching all entries IF
- SATS were stored as position amounts instead of metadata.
-
- FUTURE CONSIDERATION: If Castle's ledger format changes to store SATS as position
- amounts, this method would provide significant performance benefits for admin views.
-
- See: docs/BQL-BALANCE-QUERIES.md for detailed test results and analysis.
-
- Returns:
- [
- {
- "user_id": "abc123",
- "balance": 100000, # Currently 0 (cannot access metadata)
- "fiat_balances": {"EUR": Decimal("100.50")}, # Works correctly
- "accounts": [{"account": "...", "sats": 150000}, ...]
- },
- ...
- ]
-
- Example:
- all_balances = await fava.get_all_user_balances_bql()
- for user in all_balances:
- print(f"{user['user_id']}: {user['balance']} sats") # Will be 0 with current format
- """
- from decimal import Decimal
- import re
-
- # BQL query for ALL user accounts
- query = """
- SELECT account, sum(position) as balance
- WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-')
- AND flag != '!'
- GROUP BY account
- """
-
- result = await self.query_bql(query)
-
- # Group by user_id
- user_data = {}
-
- for row in result["rows"]:
- account_name, position = row
-
- # Extract user_id from account name
- # Format: "Liabilities:Payable:User-abc12345" or "Assets:Receivable:User-abc12345"
- if ":User-" not in account_name:
- continue
-
- user_id_with_prefix = account_name.split(":User-")[1]
- # User ID is the first 8 chars (our standard)
- user_id = user_id_with_prefix[:8]
-
- if user_id not in user_data:
- user_data[user_id] = {
- "user_id": user_id,
- "balance": 0,
- "fiat_balances": {},
- "accounts": []
- }
-
- # Process position (same logic as single-user query)
- if isinstance(position, dict):
- sats_str = position.get("SATS", "0")
- sats_amount = int(sats_str) if sats_str else 0
- user_data[user_id]["balance"] += sats_amount
-
- user_data[user_id]["accounts"].append({
- "account": account_name,
- "sats": sats_amount
- })
-
- for currency in ["EUR", "USD", "GBP"]:
- if currency in position:
- fiat_str = position[currency]
- fiat_amount = Decimal(fiat_str) if fiat_str else Decimal(0)
-
- if currency not in user_data[user_id]["fiat_balances"]:
- user_data[user_id]["fiat_balances"][currency] = Decimal(0)
- user_data[user_id]["fiat_balances"][currency] += fiat_amount
-
- elif isinstance(position, str):
- # Single currency (parse "150000 SATS" or "145.50 EUR")
- sats_match = re.match(r'^(-?\d+)\s+SATS$', position)
- if sats_match:
- sats_amount = int(sats_match.group(1))
- user_data[user_id]["balance"] += sats_amount
- user_data[user_id]["accounts"].append({
- "account": account_name,
- "sats": sats_amount
- })
- else:
- fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', position)
- if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'):
- fiat_amount = Decimal(fiat_match.group(1))
- currency = fiat_match.group(2)
-
- if currency not in user_data[user_id]["fiat_balances"]:
- user_data[user_id]["fiat_balances"][currency] = Decimal(0)
- user_data[user_id]["fiat_balances"][currency] += fiat_amount
-
- logger.info(f"Fetched balances for {len(user_data)} users (BQL)")
-
- return list(user_data.values())
-
- async def get_account_transactions(
- self,
- account_name: str,
- limit: int = 100
- ) -> List[Dict[str, Any]]:
- """
- Get all transactions affecting a specific account.
-
- Args:
- account_name: Full account name (e.g., "Assets:Receivable:User-abc123")
- limit: Maximum number of transactions
-
- Returns:
- List of transactions affecting this account
- """
- return await self.query_transactions(
- account_pattern=account_name.replace(":", "\\:"), # Escape colons for regex
- limit=limit
- )
-
- async def get_user_transactions(
- self,
- user_id: str,
- limit: int = 100
- ) -> List[Dict[str, Any]]:
- """
- Get all transactions affecting a user's accounts.
-
- Args:
- user_id: User ID
- limit: Maximum number of transactions
-
- Returns:
- List of transactions affecting user's accounts
- """
- return await self.query_transactions(
- account_pattern=f"User-{user_id[:8]}",
- limit=limit
- )
-
- async def get_all_accounts(self) -> List[Dict[str, Any]]:
- """
- Get all accounts from Beancount/Fava using BQL query.
-
- Returns:
- List of account dictionaries:
- [
- {"account": "Assets:Cash", "meta": {...}},
- {"account": "Expenses:Food", "meta": {...}},
- ...
- ]
-
- Example:
- accounts = await fava.get_all_accounts()
- for acc in accounts:
- print(acc["account"]) # "Assets:Cash"
- """
- try:
- # Use BQL to get all unique accounts
- query = "SELECT DISTINCT account"
- result = await self.query_bql(query)
-
- # Convert BQL result to expected format
- accounts = []
- for row in result["rows"]:
- account_name = row[0] if isinstance(row, list) else row.get("account")
- if account_name:
- accounts.append({
- "account": account_name,
- "meta": {} # BQL doesn't return metadata easily
- })
-
- logger.debug(f"Fava returned {len(accounts)} accounts via BQL")
- return accounts
-
- except Exception as e:
- logger.error(f"Failed to fetch accounts via BQL: {e}")
- raise
-
- async def get_journal_entries(
- self,
- days: int = None,
- start_date: str = None,
- end_date: str = None
- ) -> List[Dict[str, Any]]:
- """
- Get journal entries from Fava (with entry hashes), optionally filtered by date.
-
- Args:
- days: If provided, only return entries from the last N days.
- If None, returns all entries (default behavior).
- start_date: ISO format date string (YYYY-MM-DD). If provided with end_date,
- filters entries between start_date and end_date (inclusive).
- end_date: ISO format date string (YYYY-MM-DD). If provided with start_date,
- filters entries between start_date and end_date (inclusive).
-
- Note:
- If both days and start_date/end_date are provided, start_date/end_date takes precedence.
-
- Returns:
- List of entries (transactions, opens, closes, etc.) with entry_hash field.
-
- Example:
- # Get all entries
- entries = await fava.get_journal_entries()
-
- # Get only last 30 days
- recent = await fava.get_journal_entries(days=30)
-
- # Get entries in custom date range
- custom = await fava.get_journal_entries(start_date="2024-01-01", end_date="2024-01-31")
- """
- try:
- async with httpx.AsyncClient(timeout=self.timeout) as client:
- response = await client.get(f"{self.base_url}/journal")
- response.raise_for_status()
- result = response.json()
- entries = result.get("data", [])
- logger.info(f"Fava /journal returned {len(entries)} entries")
-
- # Filter by date range or days
- from datetime import datetime, timedelta
-
- # Use date range if both start_date and end_date are provided
- if start_date and end_date:
- try:
- filter_start = datetime.strptime(start_date, "%Y-%m-%d").date()
- filter_end = datetime.strptime(end_date, "%Y-%m-%d").date()
- filtered_entries = []
- for e in entries:
- entry_date_str = e.get("date")
- if entry_date_str:
- try:
- entry_date = datetime.strptime(entry_date_str, "%Y-%m-%d").date()
- if filter_start <= entry_date <= filter_end:
- filtered_entries.append(e)
- except (ValueError, TypeError):
- # Include entries with invalid dates (shouldn't happen)
- filtered_entries.append(e)
- logger.info(f"Filtered to {len(filtered_entries)} entries between {start_date} and {end_date}")
- entries = filtered_entries
- except ValueError as e:
- logger.error(f"Invalid date format: {e}")
- # Return all entries if date parsing fails
-
- # Fall back to days filter if no date range provided
- elif days is not None:
- cutoff_date = (datetime.now() - timedelta(days=days)).date()
- filtered_entries = []
- for e in entries:
- entry_date_str = e.get("date")
- if entry_date_str:
- try:
- entry_date = datetime.strptime(entry_date_str, "%Y-%m-%d").date()
- if entry_date >= cutoff_date:
- filtered_entries.append(e)
- except (ValueError, TypeError):
- # Include entries with invalid dates (shouldn't happen)
- filtered_entries.append(e)
- logger.info(f"Filtered to {len(filtered_entries)} entries from last {days} days (cutoff: {cutoff_date})")
- entries = filtered_entries
-
- # Log transactions with "Lightning payment" in narration
- lightning_entries = [e for e in entries if "Lightning payment" in e.get("narration", "")]
- logger.info(f"Found {len(lightning_entries)} Lightning payment entries in journal")
-
- return entries
-
- except httpx.HTTPStatusError as e:
- logger.error(f"Fava journal error: {e.response.status_code} - {e.response.text}")
- raise
- except httpx.RequestError as e:
- logger.error(f"Fava connection error: {e}")
- raise
-
- async def get_entry_context(self, entry_hash: str) -> Dict[str, Any]:
- """
- Get entry context including source text and sha256sum.
-
- Args:
- entry_hash: Entry hash from get_journal_entries()
-
- Returns:
- {
- "entry": {...}, # Serialized entry
- "slice": "2025-01-15 ! \"Description\"...", # Beancount source text
- "sha256sum": "abc123...", # For concurrency control
- "balances_before": {...},
- "balances_after": {...}
- }
-
- Example:
- context = await fava.get_entry_context("abc123")
- source = context["slice"]
- sha256sum = context["sha256sum"]
- """
- try:
- async with httpx.AsyncClient(timeout=self.timeout) as client:
- response = await client.get(
- f"{self.base_url}/context",
- params={"entry_hash": entry_hash}
- )
- response.raise_for_status()
- result = response.json()
- return result.get("data", {})
-
- except httpx.HTTPStatusError as e:
- logger.error(f"Fava context error: {e.response.status_code} - {e.response.text}")
- raise
- except httpx.RequestError as e:
- logger.error(f"Fava connection error: {e}")
- raise
-
- async def update_entry_source(self, entry_hash: str, new_source: str, sha256sum: str) -> str:
- """
- Update an entry's source text (e.g., change flag from ! to *).
-
- Args:
- entry_hash: Entry hash
- new_source: Modified Beancount source text
- sha256sum: Current sha256sum from get_entry_context() for concurrency control
-
- Returns:
- New sha256sum after update
-
- Example:
- # Get context
- context = await fava.get_entry_context("abc123")
- source = context["slice"]
- sha256 = context["sha256sum"]
-
- # Change flag
- new_source = source.replace("2025-01-15 !", "2025-01-15 *")
-
- # Update
- new_sha256 = await fava.update_entry_source("abc123", new_source, sha256)
- """
- try:
- async with httpx.AsyncClient(timeout=self.timeout) as client:
- response = await client.put(
- f"{self.base_url}/source_slice",
- json={
- "entry_hash": entry_hash,
- "source": new_source,
- "sha256sum": sha256sum
- }
- )
- response.raise_for_status()
- result = response.json()
- return result.get("data", "")
-
- except httpx.HTTPStatusError as e:
- logger.error(f"Fava update error: {e.response.status_code} - {e.response.text}")
- raise
- except httpx.RequestError as e:
- logger.error(f"Fava connection error: {e}")
- raise
-
- async def delete_entry(self, entry_hash: str, sha256sum: str) -> str:
- """
- Delete an entry from the Beancount file.
-
- Args:
- entry_hash: Entry hash
- sha256sum: Current sha256sum for concurrency control
-
- Returns:
- Success message
-
- Example:
- context = await fava.get_entry_context("abc123")
- await fava.delete_entry("abc123", context["sha256sum"])
- """
- try:
- async with httpx.AsyncClient(timeout=self.timeout) as client:
- response = await client.delete(
- f"{self.base_url}/source_slice",
- params={
- "entry_hash": entry_hash,
- "sha256sum": sha256sum
- }
- )
- response.raise_for_status()
- result = response.json()
- return result.get("data", "")
-
- except httpx.HTTPStatusError as e:
- logger.error(f"Fava delete error: {e.response.status_code} - {e.response.text}")
- raise
- except httpx.RequestError as e:
- logger.error(f"Fava connection error: {e}")
- raise
-
- async def add_account(
- self,
- account_name: str,
- currencies: list[str],
- opening_date: Optional[date] = None,
- metadata: Optional[Dict[str, Any]] = None
- ) -> Dict[str, Any]:
- """
- Add an account to the Beancount ledger via an Open directive.
-
- NOTE: Fava's /api/add_entries endpoint does NOT support Open directives.
- This method uses /api/source to directly edit the Beancount file.
-
- Args:
- account_name: Full account name (e.g., "Assets:Receivable:User-abc123")
- currencies: List of currencies for this account (e.g., ["EUR", "SATS"])
- opening_date: Date to open the account (defaults to today)
- metadata: Optional metadata for the account
-
- Returns:
- Response from Fava ({"data": "new_sha256sum", "mtime": "..."})
-
- Example:
- # Add a user's receivable account
- result = await fava.add_account(
- account_name="Assets:Receivable:User-abc123",
- currencies=["EUR", "SATS", "USD"],
- metadata={"user_id": "abc123", "description": "User receivables"}
- )
-
- # Add a user's payable account
- result = await fava.add_account(
- account_name="Liabilities:Payable:User-abc123",
- currencies=["EUR", "SATS"]
- )
- """
- from datetime import date as date_type
-
- if opening_date is None:
- opening_date = date_type.today()
-
- try:
- async with httpx.AsyncClient(timeout=self.timeout) as client:
- # Step 1: Get the main Beancount file path from Fava
- options_response = await client.get(f"{self.base_url}/options")
- options_response.raise_for_status()
- options_data = options_response.json()["data"]
- file_path = options_data["beancount_options"]["filename"]
-
- logger.debug(f"Fava main file: {file_path}")
-
- # Step 2: Get current source file
- response = await client.get(
- f"{self.base_url}/source",
- params={"filename": file_path}
- )
- response.raise_for_status()
- source_data = response.json()["data"]
-
- sha256sum = source_data["sha256sum"]
- source = source_data["source"]
-
- # Step 2: Check if account already exists
- if f"open {account_name}" in source:
- logger.info(f"Account {account_name} already exists in Beancount file")
- return {"data": sha256sum, "mtime": source_data.get("mtime", "")}
-
- # Step 3: Find insertion point (after last Open directive AND its metadata)
- lines = source.split('\n')
- insert_index = 0
- for i, line in enumerate(lines):
- if line.strip().startswith(('open ', f'{opening_date.year}-')) and 'open' in line:
- # Found an Open directive, now skip over any metadata lines
- insert_index = i + 1
- # Skip metadata lines (lines starting with whitespace)
- while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip():
- insert_index += 1
-
- # Step 4: Format Open directive as Beancount text
- currencies_str = ", ".join(currencies)
- open_lines = [
- "",
- f"{opening_date.isoformat()} open {account_name} {currencies_str}"
- ]
-
- # Add metadata if provided
- if metadata:
- for key, value in metadata.items():
- # Format metadata with proper indentation
- if isinstance(value, str):
- open_lines.append(f' {key}: "{value}"')
- else:
- open_lines.append(f' {key}: {value}')
-
- # Step 5: Insert into source
- for i, line in enumerate(open_lines):
- lines.insert(insert_index + i, line)
-
- new_source = '\n'.join(lines)
-
- # Step 6: Update source file via PUT /api/source
- update_payload = {
- "file_path": file_path,
- "source": new_source,
- "sha256sum": sha256sum
- }
-
- response = await client.put(
- f"{self.base_url}/source",
- json=update_payload,
- headers={"Content-Type": "application/json"}
- )
- response.raise_for_status()
- result = response.json()
-
- logger.info(f"Added account {account_name} to Beancount file with currencies {currencies}")
- return result
-
- except httpx.HTTPStatusError as e:
- logger.error(f"Fava HTTP error adding account: {e.response.status_code} - {e.response.text}")
- raise
- except httpx.RequestError as e:
- logger.error(f"Fava connection error: {e}")
- raise
-
-
-# Singleton instance (configured from settings)
-_fava_client: Optional[FavaClient] = None
-
-
-def init_fava_client(fava_url: str, ledger_slug: str, timeout: float = 10.0):
- """
- Initialize the global Fava client.
-
- Args:
- fava_url: Base URL of Fava server
- ledger_slug: Ledger identifier
- timeout: Request timeout in seconds
- """
- global _fava_client
- _fava_client = FavaClient(fava_url, ledger_slug, timeout)
- logger.info(f"Fava client initialized: {fava_url}/{ledger_slug}")
-
-
-def get_fava_client() -> FavaClient:
- """
- Get the configured Fava client.
-
- Returns:
- FavaClient instance
-
- Raises:
- RuntimeError: If client not initialized
- """
- if _fava_client is None:
- raise RuntimeError(
- "Fava client not initialized. Call init_fava_client() first. "
- "Castle requires Fava for all accounting operations."
- )
- return _fava_client
diff --git a/helper/README.md b/helper/README.md
deleted file mode 100644
index 648b987..0000000
--- a/helper/README.md
+++ /dev/null
@@ -1,168 +0,0 @@
-# Castle Beancount Import Helper
-
-Import Beancount ledger transactions into Castle accounting extension.
-
-## 📁 Files
-
-- `import_beancount.py` - Main import script
-- `btc_eur_rates.csv` - Daily BTC/EUR rates (create your own)
-- `README.md` - This file
-
-## 🚀 Setup
-
-### 1. Create BTC/EUR Rates CSV
-
-Create `btc_eur_rates.csv` in this directory with your actual rates:
-
-```csv
-date,btc_eur_rate
-2025-07-01,86500
-2025-07-02,87200
-2025-07-03,87450
-```
-
-### 2. Update User Mappings
-
-Edit `import_beancount.py` and update the `USER_MAPPINGS` dictionary:
-
-```python
-USER_MAPPINGS = {
- "Pat": "actual_wallet_id_for_pat",
- "Alice": "actual_wallet_id_for_alice",
- "Bob": "actual_wallet_id_for_bob",
-}
-```
-
-**How to get wallet IDs:**
-- Check your LNbits admin panel
-- Or query: `curl -X GET http://localhost:5000/api/v1/wallet -H "X-Api-Key: user_invoice_key"`
-
-### 3. Set API Key
-
-```bash
-export CASTLE_ADMIN_KEY="your_lnbits_admin_invoice_key"
-export LNBITS_URL="http://localhost:5000" # Optional
-```
-
-## 📖 Usage
-
-```bash
-cd /path/to/castle/helper
-
-# Test with dry run
-python import_beancount.py ledger.beancount --dry-run
-
-# Actually import
-python import_beancount.py ledger.beancount
-```
-
-## 📄 Beancount File Format
-
-Your Beancount transactions must have an `Equity:` account:
-
-```beancount
-2025-07-06 * "Foix market"
- Expenses:Groceries 69.40 EUR
- Equity:Pat
-
-2025-07-07 * "Gas station"
- Expenses:Transport 45.00 EUR
- Equity:Alice
-```
-
-**Requirements:**
-- Every transaction must have an `Equity:` account
-- Account names must match exactly what's in Castle
-- The name after `Equity:` must be in `USER_MAPPINGS`
-
-## 🔄 How It Works
-
-1. **Loads rates** from `btc_eur_rates.csv`
-2. **Loads accounts** from Castle API automatically
-3. **Maps users** - Extracts user name from `Equity:Name` accounts
-4. **Parses** Beancount transactions
-5. **Converts** EUR → sats using daily rate
-6. **Uploads** to Castle with metadata
-
-## 📊 Example Output
-
-```bash
-$ python import_beancount.py ledger.beancount
-======================================================================
-🏰 Beancount to Castle Import Script
-======================================================================
-
-📊 Loaded 15 daily rates from btc_eur_rates.csv
- Date range: 2025-07-01 to 2025-07-15
-
-🏦 Loaded 28 accounts from Castle
-
-👥 User ID mappings:
- - Pat → wallet_abc123
- - Alice → wallet_def456
- - Bob → wallet_ghi789
-
-📄 Found 25 potential transactions in ledger.beancount
-
-✅ Transaction 1: 2025-07-06 - Foix market (User: Pat) (Rate: 87,891 EUR/BTC)
-✅ Transaction 2: 2025-07-07 - Gas station (User: Alice) (Rate: 88,100 EUR/BTC)
-✅ Transaction 3: 2025-07-08 - Restaurant (User: Bob) (Rate: 88,350 EUR/BTC)
-
-======================================================================
-📊 Summary: 25 succeeded, 0 failed, 0 skipped
-======================================================================
-
-✅ Successfully imported 25 transactions to Castle!
-```
-
-## ❓ Troubleshooting
-
-### "No account found in Castle"
-**Error:** `No account found in Castle with name 'Expenses:XYZ'`
-
-**Solution:** Create the account in Castle first with that exact name.
-
-### "No user ID mapping found"
-**Error:** `No user ID mapping found for 'Pat'`
-
-**Solution:** Add Pat to the `USER_MAPPINGS` dictionary in the script.
-
-### "No BTC/EUR rate found"
-**Error:** `No BTC/EUR rate found for 2025-07-15`
-
-**Solution:** Add that date to `btc_eur_rates.csv`.
-
-### "Could not determine user ID"
-**Error:** `Could not determine user ID for transaction`
-
-**Solution:** Every transaction needs an `Equity:` account (e.g., `Equity:Pat`).
-
-## 📝 Transaction Metadata
-
-Each imported transaction includes:
-
-```json
-{
- "meta": {
- "source": "beancount_import",
- "imported_at": "2025-11-08T12:00:00",
- "btc_eur_rate": 87891.0,
- "user_id": "wallet_abc123"
- }
-}
-```
-
-And each line includes:
-
-```json
-{
- "metadata": {
- "fiat_currency": "EUR",
- "fiat_amount": "69.400",
- "fiat_rate": 1137.88,
- "btc_rate": 87891.0
- }
-}
-```
-
-This preserves the original EUR amount and exchange rate for auditing.
diff --git a/helper/btc_eur_rates.csv b/helper/btc_eur_rates.csv
deleted file mode 120000
index 559e863..0000000
--- a/helper/btc_eur_rates.csv
+++ /dev/null
@@ -1 +0,0 @@
-/home/padreug/projects/historical-bitcoin-data/bitcoin_daily_prices.csv
\ No newline at end of file
diff --git a/helper/import_beancount.py b/helper/import_beancount.py
deleted file mode 100755
index 417d0fd..0000000
--- a/helper/import_beancount.py
+++ /dev/null
@@ -1,673 +0,0 @@
-#!/usr/bin/env python3
-"""
-Beancount to Castle Import Script
-
-⚠️ NOTE: This script is for ONE-OFF MIGRATION purposes only.
-
- Now that Castle uses Fava/Beancount as the single source of truth,
- the data flow is: Castle → Fava/Beancount (not the reverse).
-
- This script was used for initial data import from existing Beancount files.
-
- Future disposition:
- - DELETE if no longer needed for migrations
- - REPURPOSE for bidirectional sync if that becomes a requirement
- - ARCHIVE to misc-docs/old-helpers/ if keeping for reference
-
-Imports Beancount ledger transactions into Castle accounting extension.
-Reads daily BTC/EUR rates from btc_eur_rates.csv in the same directory.
-
-Usage:
- python import_beancount.py [--dry-run]
-
-Example:
- python import_beancount.py my_ledger.beancount --dry-run
- python import_beancount.py my_ledger.beancount
-"""
-import requests
-import csv
-import os
-from datetime import datetime, timedelta
-from decimal import Decimal
-from typing import Dict, Optional
-
-# ===== CONFIGURATION =====
-
-# LNbits URL and API Key
-LNBITS_URL = os.environ.get("LNBITS_URL", "http://localhost:5000")
-ADMIN_API_KEY = os.environ.get("CASTLE_ADMIN_KEY", "48d787d862484a6c89d6a557b4d5be9d")
-
-# Rates CSV file (looks in same directory as this script)
-SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
-RATES_CSV_FILE = os.path.join(SCRIPT_DIR, "btc_eur_rates.csv")
-
-# User ID mappings: Equity account name -> Castle user ID (wallet ID)
-# TODO: Update these with your actual Castle user/wallet IDs
-USER_MAPPINGS = {
- "Pat": "75be145a42884b22b60bf97510ed46e3",
- "Coco": "375ec158ceca46de86cf6561ca20f881",
- "Charlie": "921340b802104c25901eae6c420b1ba1",
-}
-
-# ===== RATE LOOKUP =====
-
-class RateLookup:
- """Load and lookup BTC/EUR rates from CSV file"""
-
- def __init__(self, csv_file: str):
- self.rates = {}
- self._load_csv(csv_file)
-
- def _load_csv(self, csv_file: str):
- """Load rates from CSV file"""
- if not os.path.exists(csv_file):
- raise FileNotFoundError(
- f"Rates CSV file not found: {csv_file}\n"
- f"Please create btc_eur_rates.csv in the same directory as this script."
- )
-
- with open(csv_file, 'r', encoding='utf-8') as f:
- reader = csv.DictReader(f)
- for row in reader:
- date = datetime.strptime(row['date'], '%Y-%m-%d').date()
- # Handle comma as thousands separator
- rate_str = row['btc_eur_rate'].replace(',', '').replace(' ', '')
- rate = float(rate_str)
- self.rates[date] = rate
-
- if not self.rates:
- raise ValueError(f"No rates loaded from {csv_file}")
-
- print(f"📊 Loaded {len(self.rates)} daily rates from {os.path.basename(csv_file)}")
- print(f" Date range: {min(self.rates.keys())} to {max(self.rates.keys())}")
-
- def get_rate(self, date: datetime.date, fallback_days: int = 7) -> Optional[float]:
- """
- Get BTC/EUR rate for a specific date.
- If exact date not found, tries nearby dates within fallback_days.
-
- Args:
- date: Date to lookup
- fallback_days: How many days to look back/forward if exact date missing
-
- Returns:
- BTC/EUR rate or None if not found
- """
- # Try exact date first
- if date in self.rates:
- return self.rates[date]
-
- # Try nearby dates (prefer earlier dates)
- for days_offset in range(1, fallback_days + 1):
- # Try earlier date first
- earlier = date - timedelta(days=days_offset)
- if earlier in self.rates:
- print(f" ⚠️ Using rate from {earlier} for {date} (exact date not found)")
- return self.rates[earlier]
-
- # Try later date
- later = date + timedelta(days=days_offset)
- if later in self.rates:
- print(f" ⚠️ Using rate from {later} for {date} (exact date not found)")
- return self.rates[later]
-
- return None
-
-# ===== ACCOUNT LOOKUP =====
-
-class AccountLookup:
- """Fetch and lookup Castle accounts from API"""
-
- def __init__(self, lnbits_url: str, api_key: str):
- self.accounts = {} # name -> account_id
- self.accounts_by_user = {} # user_id -> {account_type -> account_id}
- self.account_details = [] # Full account objects
- self._fetch_accounts(lnbits_url, api_key)
-
- def _fetch_accounts(self, lnbits_url: str, api_key: str):
- """Fetch all accounts from Castle API"""
- url = f"{lnbits_url}/castle/api/v1/accounts"
- headers = {"X-Api-Key": api_key}
-
- try:
- response = requests.get(url, headers=headers)
- response.raise_for_status()
- accounts_list = response.json()
-
- # Build mappings
- for account in accounts_list:
- name = account.get('name')
- account_id = account.get('id')
- user_id = account.get('user_id')
- account_type = account.get('account_type')
-
- self.account_details.append(account)
-
- # Name -> ID mapping
- if name and account_id:
- self.accounts[name] = account_id
-
- # User -> Account Type -> ID mapping (for equity accounts)
- if user_id and account_type:
- if user_id not in self.accounts_by_user:
- self.accounts_by_user[user_id] = {}
- self.accounts_by_user[user_id][account_type] = account_id
-
- print(f"🏦 Loaded {len(self.accounts)} accounts from Castle")
-
- except requests.RequestException as e:
- raise ConnectionError(f"Failed to fetch accounts from Castle API: {e}")
-
- def get_account_id(self, account_name: str) -> Optional[str]:
- """
- Get Castle account ID for a Beancount account name.
-
- Special handling for user-specific accounts:
- - "Liabilities:Payable:Pat" -> looks up Pat's user_id and finds their Castle payable account
- - "Assets:Receivable:Pat" -> looks up Pat's user_id and finds their Castle receivable account
- - "Equity:Pat" -> looks up Pat's user_id and finds their Castle equity account
-
- Args:
- account_name: Beancount account name (e.g., "Expenses:Food:Supplies", "Liabilities:Payable:Pat", "Assets:Receivable:Pat", "Equity:Pat")
-
- Returns:
- Castle account UUID or None if not found
- """
- # Check if this is a Liabilities:Payable: account
- # Map Beancount Liabilities:Payable:Pat to Castle Liabilities:Payable:User-
- if account_name.startswith("Liabilities:Payable:"):
- user_name = extract_user_from_user_account(account_name)
- if user_name:
- # Look up user's actual user_id
- user_id = USER_MAPPINGS.get(user_name)
- if user_id:
- # Find this user's liability (payable) account
- # This is the Liabilities:Payable:User- account in Castle
- if user_id in self.accounts_by_user:
- liability_account_id = self.accounts_by_user[user_id].get('liability')
- if liability_account_id:
- return liability_account_id
-
- # If not found, provide helpful error
- raise ValueError(
- f"User '{user_name}' (ID: {user_id}) does not have a payable account.\n"
- f"This should have been created when they configured their wallet.\n"
- f"Please configure the wallet for user ID: {user_id}"
- )
-
- # Check if this is an Assets:Receivable: account
- # Map Beancount Assets:Receivable:Pat to Castle Assets:Receivable:User-
- elif account_name.startswith("Assets:Receivable:"):
- user_name = extract_user_from_user_account(account_name)
- if user_name:
- # Look up user's actual user_id
- user_id = USER_MAPPINGS.get(user_name)
- if user_id:
- # Find this user's asset (receivable) account
- # This is the Assets:Receivable:User- account in Castle
- if user_id in self.accounts_by_user:
- asset_account_id = self.accounts_by_user[user_id].get('asset')
- if asset_account_id:
- return asset_account_id
-
- # If not found, provide helpful error
- raise ValueError(
- f"User '{user_name}' (ID: {user_id}) does not have a receivable account.\n"
- f"This should have been created when they configured their wallet.\n"
- f"Please configure the wallet for user ID: {user_id}"
- )
-
- # Check if this is an Equity: account
- # Map Beancount Equity:Pat to Castle Equity:User-
- elif account_name.startswith("Equity:"):
- user_name = extract_user_from_user_account(account_name)
- if user_name:
- # Look up user's actual user_id
- user_id = USER_MAPPINGS.get(user_name)
- if user_id:
- # Find this user's equity account
- # This is the Equity:User- account in Castle
- if user_id in self.accounts_by_user:
- equity_account_id = self.accounts_by_user[user_id].get('equity')
- if equity_account_id:
- return equity_account_id
-
- # If not found, provide helpful error
- raise ValueError(
- f"User '{user_name}' (ID: {user_id}) does not have an equity account.\n"
- f"Equity eligibility must be enabled for this user in Castle.\n"
- f"Please enable equity for user ID: {user_id}"
- )
-
- # Normal account lookup by name
- return self.accounts.get(account_name)
-
- def list_accounts(self):
- """Print all available accounts"""
- print("\n📋 Available accounts:")
- for name in sorted(self.accounts.keys()):
- print(f" - {name}")
-
-# ===== CONVERSION FUNCTIONS =====
-
-def sanitize_link(text: str) -> str:
- """
- Sanitize a string to make it valid for Beancount links.
-
- Beancount links can only contain: A-Z, a-z, 0-9, -, _, /, .
- All other characters are replaced with hyphens.
-
- Examples:
- >>> sanitize_link("Test (pending)")
- 'Test-pending'
- >>> sanitize_link("Invoice #123")
- 'Invoice-123'
- >>> sanitize_link("import-20250623-Action Ressourcerie")
- 'import-20250623-Action-Ressourcerie'
- """
- import re
- # Replace any character that's not alphanumeric, dash, underscore, slash, or period with a hyphen
- sanitized = re.sub(r'[^A-Za-z0-9\-_/.]', '-', text)
- # Remove consecutive hyphens
- sanitized = re.sub(r'-+', '-', sanitized)
- # Remove leading/trailing hyphens
- sanitized = sanitized.strip('-')
- return sanitized
-
-def eur_to_sats(eur_amount: Decimal, btc_eur_rate: float) -> int:
- """Convert EUR to satoshis using BTC/EUR rate"""
- btc_amount = eur_amount / Decimal(str(btc_eur_rate))
- sats = btc_amount * Decimal(100_000_000)
- return int(sats.quantize(Decimal('1')))
-
-def build_metadata(eur_amount: Decimal, btc_eur_rate: float) -> dict:
- """
- Build metadata dict for Castle entry line.
-
- The API will extract fiat_currency and fiat_amount and use them
- to create proper EUR-based postings with SATS in metadata.
- """
- abs_eur = abs(eur_amount)
- abs_sats = abs(eur_to_sats(abs_eur, btc_eur_rate))
-
- return {
- "fiat_currency": "EUR",
- "fiat_amount": str(abs_eur.quantize(Decimal("0.01"))), # Store as string for JSON
- "btc_rate": str(btc_eur_rate) # Store exchange rate for reference
- }
-
-# ===== BEANCOUNT PARSER =====
-
-def parse_beancount_transaction(txn_text: str) -> Optional[Dict]:
- """
- Parse a Beancount transaction.
-
- Expected format:
- 2025-07-06 * "Foix market"
- Expenses:Groceries 69.40 EUR
- Equity:Pat
- """
- lines = txn_text.strip().split('\n')
- if not lines:
- return None
-
- # Skip leading comments to find the transaction header
- header_line_idx = 0
- for i, line in enumerate(lines):
- stripped = line.strip()
- # Skip comments and empty lines
- if not stripped or stripped.startswith(';'):
- continue
- # Found the first non-comment line
- header_line_idx = i
- break
-
- # Parse header line
- header = lines[header_line_idx].strip()
-
- # Handle both * and ! flags
- if '*' in header:
- parts = header.split('*')
- flag = '*'
- elif '!' in header:
- parts = header.split('!')
- flag = '!'
- else:
- return None
-
- date_str = parts[0].strip()
- description = parts[1].strip().strip('"')
-
- try:
- date = datetime.strptime(date_str, '%Y-%m-%d')
- except ValueError:
- return None
-
- # Parse postings (start after the header line)
- postings = []
- for line in lines[header_line_idx + 1:]:
- line = line.strip()
-
- # Skip comments and empty lines
- if not line or line.startswith(';'):
- continue
-
- # Parse posting line
- parts = line.split()
- if not parts:
- continue
-
- account = parts[0]
-
- # Check if amount is specified
- if len(parts) >= 3 and parts[-1] == 'EUR':
- # Strip commas from amount (e.g., "1,500.00" -> "1500.00")
- amount_str = parts[-2].replace(',', '')
- eur_amount = Decimal(amount_str)
- else:
- # No amount specified - will be calculated to balance
- eur_amount = None
-
- postings.append({
- 'account': account,
- 'eur_amount': eur_amount
- })
-
- # Calculate missing amounts (Beancount auto-balance)
- # TODO: Support auto-balancing for transactions with >2 postings
- # For now, only handles simple 2-posting transactions
- if len(postings) == 2:
- if postings[0]['eur_amount'] and not postings[1]['eur_amount']:
- postings[1]['eur_amount'] = -postings[0]['eur_amount']
- elif postings[1]['eur_amount'] and not postings[0]['eur_amount']:
- postings[0]['eur_amount'] = -postings[1]['eur_amount']
-
- return {
- 'date': date,
- 'description': description,
- 'postings': postings
- }
-
-# ===== HELPER FUNCTIONS =====
-
-def extract_user_from_user_account(account_name: str) -> Optional[str]:
- """
- Extract user name from user-specific accounts (Payable, Receivable, or Equity).
-
- Examples:
- "Liabilities:Payable:Pat" -> "Pat"
- "Assets:Receivable:Alice" -> "Alice"
- "Equity:Pat" -> "Pat"
- "Expenses:Food" -> None
-
- Returns:
- User name or None if not a user-specific account
- """
- if account_name.startswith("Liabilities:Payable:"):
- parts = account_name.split(":")
- if len(parts) >= 3:
- return parts[2]
- elif account_name.startswith("Assets:Receivable:"):
- parts = account_name.split(":")
- if len(parts) >= 3:
- return parts[2]
- elif account_name.startswith("Equity:"):
- parts = account_name.split(":")
- if len(parts) >= 2:
- return parts[1]
- return None
-
-def determine_user_id(postings: list) -> Optional[str]:
- """
- Determine which user ID to use for this transaction based on user-specific accounts.
-
- Args:
- postings: List of posting dicts with 'account' key
-
- Returns:
- User ID (wallet ID) from USER_MAPPINGS, or None if no user account found
- """
- for posting in postings:
- user_name = extract_user_from_user_account(posting['account'])
- if user_name:
- user_id = USER_MAPPINGS.get(user_name)
- if not user_id:
- raise ValueError(
- f"No user ID mapping found for '{user_name}'.\n"
- f"Please add '{user_name}' to USER_MAPPINGS in the script."
- )
- return user_id
-
- # No user-specific account found - this shouldn't happen for typical transactions
- return None
-
-# ===== CASTLE CONVERTER =====
-
-def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: AccountLookup) -> dict:
- """
- Convert parsed Beancount transaction to Castle format.
-
- Sends SATS amounts with fiat metadata. The Castle API will automatically
- convert to EUR-based postings with SATS stored in metadata.
- """
-
- # Determine which user this transaction is for (based on user-specific accounts)
- user_id = determine_user_id(parsed['postings'])
- if not user_id:
- raise ValueError(
- f"Could not determine user ID for transaction.\n"
- f"Transactions must have a user-specific account:\n"
- f" - Liabilities:Payable: (for payables)\n"
- f" - Assets:Receivable: (for receivables)\n"
- f" - Equity: (for equity)\n"
- f"Examples: Liabilities:Payable:Pat, Assets:Receivable:Pat, Equity:Pat"
- )
-
- # Build entry lines
- lines = []
- for posting in parsed['postings']:
- account_id = account_lookup.get_account_id(posting['account'])
- if not account_id:
- raise ValueError(
- f"No account found in Castle with name '{posting['account']}'.\n"
- f"Please create this account in Castle first."
- )
-
- eur_amount = posting['eur_amount']
- if eur_amount is None:
- raise ValueError(f"Could not determine amount for {posting['account']}")
-
- # Convert EUR to sats (amount sent to API)
- sats = eur_to_sats(eur_amount, btc_eur_rate)
-
- # Build metadata (API will extract fiat_currency and fiat_amount)
- metadata = build_metadata(eur_amount, btc_eur_rate)
-
- lines.append({
- "account_id": account_id,
- "amount": sats, # Positive = debit, negative = credit
- "description": posting['account'],
- "metadata": metadata
- })
-
- # Create sanitized reference link
- desc_part = sanitize_link(parsed['description'][:30])
-
- return {
- "description": parsed['description'],
- "entry_date": parsed['date'].isoformat(),
- "reference": f"import-{parsed['date'].strftime('%Y%m%d')}-{desc_part}",
- "flag": "*",
- "meta": {
- "source": "beancount_import",
- "imported_at": datetime.now().isoformat(),
- "btc_eur_rate": str(btc_eur_rate),
- "user_id": user_id # Track which user this transaction is for
- },
- "lines": lines
- }
-
-# ===== API UPLOAD =====
-
-def upload_entry(entry: dict, api_key: str, dry_run: bool = False) -> dict:
- """Upload journal entry to Castle API"""
- if dry_run:
- print(f"\n[DRY RUN] Entry preview:")
- print(f" Description: {entry['description']}")
- print(f" Date: {entry['entry_date']}")
- print(f" BTC/EUR Rate: {entry['meta']['btc_eur_rate']:,.2f}")
- total_sats = 0
- for line in entry['lines']:
- sign = '+' if line['amount'] > 0 else ''
- print(f" {line['description']}: {sign}{line['amount']:,} sats "
- f"({line['metadata']['fiat_amount']} EUR)")
- total_sats += line['amount']
- print(f" Balance check: {total_sats} (should be 0)")
- return {"id": "dry-run"}
-
- url = f"{LNBITS_URL}/castle/api/v1/entries"
- headers = {
- "X-Api-Key": api_key,
- "Content-Type": "application/json"
- }
-
- try:
- response = requests.post(url, json=entry, headers=headers)
- response.raise_for_status()
- return response.json()
- except requests.exceptions.HTTPError as e:
- print(f" ❌ HTTP Error: {e}")
- if response.text:
- print(f" Response: {response.text}")
- raise
- except Exception as e:
- print(f" ❌ Error: {e}")
- raise
-
-# ===== MAIN IMPORT FUNCTION =====
-
-def import_beancount_file(beancount_file: str, dry_run: bool = False):
- """Import transactions from Beancount file using rates from CSV"""
-
- # Validate configuration
- if not ADMIN_API_KEY:
- print("❌ Error: CASTLE_ADMIN_KEY not set!")
- print(" Set it as environment variable or update ADMIN_API_KEY in the script.")
- return
-
- # Load rates
- try:
- rate_lookup = RateLookup(RATES_CSV_FILE)
- except (FileNotFoundError, ValueError) as e:
- print(f"❌ Error loading rates: {e}")
- return
-
- # Load accounts from Castle
- try:
- account_lookup = AccountLookup(LNBITS_URL, ADMIN_API_KEY)
- except (ConnectionError, ValueError) as e:
- print(f"❌ Error loading accounts: {e}")
- return
-
- # Show user mappings and verify equity accounts exist
- print(f"\n👥 User ID mappings and equity accounts:")
- for name, user_id in USER_MAPPINGS.items():
- has_equity = user_id in account_lookup.accounts_by_user and 'equity' in account_lookup.accounts_by_user[user_id]
- status = "✅" if has_equity else "❌"
- print(f" {status} {name} → {user_id} {'(has equity account)' if has_equity else '(NO EQUITY ACCOUNT - create in Castle!)'}")
-
- # Read beancount file
- if not os.path.exists(beancount_file):
- print(f"❌ Error: Beancount file not found: {beancount_file}")
- return
-
- with open(beancount_file, 'r', encoding='utf-8') as f:
- content = f.read()
-
- # Split by blank lines to get individual transactions
- transactions = [t.strip() for t in content.split('\n\n') if t.strip()]
-
- print(f"\n📄 Found {len(transactions)} potential transactions in {os.path.basename(beancount_file)}")
- if dry_run:
- print("🔍 [DRY RUN MODE] No changes will be made\n")
-
- success_count = 0
- error_count = 0
- skip_count = 0
- skipped_items = [] # Track what was skipped
-
- for i, txn_text in enumerate(transactions, 1):
- try:
- # Try to parse the transaction
- parsed = parse_beancount_transaction(txn_text)
- if not parsed:
- # Not a valid transaction (likely a directive, option, or comment block)
- skip_count += 1
- first_line = txn_text.split('\n')[0][:60]
- skipped_items.append(f"Entry {i}: {first_line}... (not a transaction)")
- continue
-
- # Look up rate for this transaction's date
- btc_eur_rate = rate_lookup.get_rate(parsed['date'].date())
- if not btc_eur_rate:
- raise ValueError(f"No BTC/EUR rate found for {parsed['date'].date()}")
-
- castle_entry = convert_to_castle_entry(parsed, btc_eur_rate, account_lookup)
- result = upload_entry(castle_entry, ADMIN_API_KEY, dry_run)
-
- # Get user name for display
- user_name = None
- for posting in parsed['postings']:
- user_name = extract_user_from_user_account(posting['account'])
- if user_name:
- break
-
- user_info = f" (User: {user_name})" if user_name else ""
- print(f"✅ Transaction {i}: {parsed['date'].date()} - {parsed['description'][:35]}{user_info} "
- f"(Rate: {btc_eur_rate:,.0f} EUR/BTC)")
- success_count += 1
-
- except Exception as e:
- print(f"❌ Transaction {i} failed: {e}")
- print(f" Content: {txn_text[:100]}...")
- error_count += 1
-
- print(f"\n{'='*70}")
- print(f"📊 Summary: {success_count} succeeded, {error_count} failed, {skip_count} skipped")
- print(f"{'='*70}")
-
- # Show details of skipped entries
- if skipped_items:
- print(f"\n⏭️ Skipped entries:")
- for item in skipped_items:
- print(f" {item}")
-
- if success_count > 0 and not dry_run:
- 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" Check Fava to see the imported entries.")
-
-# ===== MAIN =====
-
-if __name__ == "__main__":
- import sys
-
- print("=" * 70)
- print("🏰 Beancount to Castle Import Script")
- print("=" * 70)
-
- if len(sys.argv) < 2:
- print("\nUsage: python import_beancount.py [--dry-run]")
- print("\nExample:")
- print(" python import_beancount.py my_ledger.beancount --dry-run")
- print(" python import_beancount.py my_ledger.beancount")
- print("\nConfiguration:")
- print(f" LNBITS_URL: {LNBITS_URL}")
- print(f" RATES_CSV: {RATES_CSV_FILE}")
- print(f" API Key set: {'Yes' if ADMIN_API_KEY else 'No (set CASTLE_ADMIN_KEY env var)'}")
- sys.exit(1)
-
- beancount_file = sys.argv[1]
- dry_run = "--dry-run" in sys.argv
-
- import_beancount_file(beancount_file, dry_run)
diff --git a/migrations.py b/migrations.py
index c9a7e30..5efb00d 100644
--- a/migrations.py
+++ b/migrations.py
@@ -1,71 +1,13 @@
-"""
-Castle Extension Database Migrations
-
-This file contains a single squashed migration that creates the complete
-database schema for the Castle extension.
-
-MIGRATION HISTORY:
-This is a squashed migration that combines m001-m016 from the original
-incremental migration history. The complete historical migrations are
-preserved in migrations_old.py.bak for reference.
-
-Key schema decisions reflected in this migration:
-1. Hierarchical Beancount-style account names (e.g., "Assets:Bitcoin:Lightning")
-2. No journal_entries/entry_lines tables (Fava is source of truth)
-3. User-specific equity accounts created dynamically (Equity:User-{user_id})
-4. Parent-only accounts removed (hierarchy implicit in colon-separated names)
-5. Multi-currency support via balance_assertions
-6. Granular permission system via account_permissions
-
-Original migration sequence (Nov 2025):
-- m001: Initial accounts, journal_entries, entry_lines tables
-- m002: Extension settings
-- m003: User wallet settings
-- m004: Manual payment requests
-- m005: Added flag/meta to journal entries
-- m006: Migrated to hierarchical account names
-- m007: Balance assertions
-- m008: Renamed Lightning account
-- m009: Added OnChain Bitcoin account
-- m010: User equity status
-- m011: Account permissions
-- m012: Updated default accounts with detailed hierarchy
-- m013: Removed parent-only accounts (Assets:Bitcoin, Equity)
-- m014: Removed legacy equity accounts (MemberEquity, RetainedEarnings)
-- m015: Converted entry_lines to single amount field
-- m016: Dropped journal_entries and entry_lines tables (Fava integration)
-"""
-
-
async def m001_initial(db):
"""
- Initial Castle database schema (squashed from m001-m016).
-
- Creates complete database structure for Castle accounting extension:
- - Accounts: Chart of accounts with hierarchical Beancount-style names
- - Extension settings: Castle-wide configuration
- - User wallet settings: Per-user wallet configuration
- - Manual payment requests: User-submitted payment requests to Castle
- - Balance assertions: Reconciliation and balance checking
- - User equity status: Equity contribution eligibility
- - Account permissions: Granular access control
-
- Note: Journal entries are managed by Fava/Beancount (external source of truth).
- Castle submits entries to Fava and queries Fava for journal data.
+ Initial migration for Castle accounting extension.
+ Creates tables for double-entry bookkeeping system.
"""
-
- # =========================================================================
- # ACCOUNTS TABLE
- # =========================================================================
- # Core chart of accounts with hierarchical Beancount-style naming.
- # Examples: "Assets:Bitcoin:Lightning", "Expenses:Food:Groceries"
- # User-specific accounts: "Assets:Receivable:User-af983632"
-
await db.execute(
f"""
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
- name TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
account_type TEXT NOT NULL,
description TEXT,
user_id TEXT,
@@ -86,29 +28,113 @@ async def m001_initial(db):
"""
)
- # =========================================================================
- # EXTENSION SETTINGS TABLE
- # =========================================================================
- # Castle-wide configuration settings
+ await db.execute(
+ f"""
+ CREATE TABLE journal_entries (
+ id TEXT PRIMARY KEY,
+ description TEXT NOT NULL,
+ entry_date TIMESTAMP NOT NULL,
+ created_by TEXT NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
+ reference TEXT
+ );
+ """
+ )
+ await db.execute(
+ """
+ CREATE INDEX idx_journal_entries_created_by ON journal_entries (created_by);
+ """
+ )
+
+ await db.execute(
+ """
+ CREATE INDEX idx_journal_entries_date ON journal_entries (entry_date);
+ """
+ )
+
+ await db.execute(
+ f"""
+ CREATE TABLE entry_lines (
+ id TEXT PRIMARY KEY,
+ journal_entry_id TEXT NOT NULL,
+ account_id TEXT NOT NULL,
+ debit INTEGER NOT NULL DEFAULT 0,
+ credit INTEGER NOT NULL DEFAULT 0,
+ description TEXT,
+ metadata TEXT DEFAULT '{{}}'
+ );
+ """
+ )
+
+ await db.execute(
+ """
+ CREATE INDEX idx_entry_lines_journal_entry ON entry_lines (journal_entry_id);
+ """
+ )
+
+ await db.execute(
+ """
+ CREATE INDEX idx_entry_lines_account ON entry_lines (account_id);
+ """
+ )
+
+ # Insert default chart of accounts
+ default_accounts = [
+ # Assets
+ ("cash", "Cash", "asset", "Cash on hand"),
+ ("bank", "Bank Account", "asset", "Bank account"),
+ ("lightning", "Lightning Balance", "asset", "Lightning Network balance"),
+ ("accounts_receivable", "Accounts Receivable", "asset", "Money owed to the Castle"),
+
+ # Liabilities
+ ("accounts_payable", "Accounts Payable", "liability", "Money owed by the Castle"),
+
+ # Equity
+ ("member_equity", "Member Equity", "equity", "Member contributions"),
+ ("retained_earnings", "Retained Earnings", "equity", "Accumulated profits"),
+
+ # Revenue
+ ("accommodation_revenue", "Accommodation Revenue", "revenue", "Revenue from stays"),
+ ("service_revenue", "Service Revenue", "revenue", "Revenue from services"),
+ ("other_revenue", "Other Revenue", "revenue", "Other revenue"),
+
+ # Expenses
+ ("utilities", "Utilities", "expense", "Electricity, water, internet"),
+ ("food", "Food & Supplies", "expense", "Food and supplies"),
+ ("maintenance", "Maintenance", "expense", "Repairs and maintenance"),
+ ("other_expense", "Other Expenses", "expense", "Miscellaneous expenses"),
+ ]
+
+ for acc_id, name, acc_type, desc in default_accounts:
+ await db.execute(
+ """
+ INSERT INTO accounts (id, name, account_type, description)
+ VALUES (:id, :name, :type, :description)
+ """,
+ {"id": acc_id, "name": name, "type": acc_type, "description": desc}
+ )
+
+
+async def m002_extension_settings(db):
+ """
+ Create extension_settings table for Castle configuration.
+ """
await db.execute(
f"""
CREATE TABLE extension_settings (
id TEXT NOT NULL PRIMARY KEY,
castle_wallet_id TEXT,
- fava_url TEXT NOT NULL DEFAULT 'http://localhost:3333',
- fava_ledger_slug TEXT NOT NULL DEFAULT 'castle-ledger',
- fava_timeout REAL NOT NULL DEFAULT 10.0,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
- # =========================================================================
- # USER WALLET SETTINGS TABLE
- # =========================================================================
- # Per-user wallet configuration
+async def m003_user_wallet_settings(db):
+ """
+ Create user_wallet_settings table for per-user wallet configuration.
+ """
await db.execute(
f"""
CREATE TABLE user_wallet_settings (
@@ -119,11 +145,11 @@ async def m001_initial(db):
"""
)
- # =========================================================================
- # MANUAL PAYMENT REQUESTS TABLE
- # =========================================================================
- # User-submitted payment requests to Castle (reviewed by admins)
+async def m004_manual_payment_requests(db):
+ """
+ Create manual_payment_requests table for user payment requests to Castle.
+ """
await db.execute(
f"""
CREATE TABLE manual_payment_requests (
@@ -131,7 +157,6 @@ async def m001_initial(db):
user_id TEXT NOT NULL,
amount INTEGER NOT NULL,
description TEXT NOT NULL,
- notes TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
reviewed_at TIMESTAMP,
@@ -143,24 +168,115 @@ async def m001_initial(db):
await db.execute(
"""
- CREATE INDEX idx_manual_payment_requests_user_id
- ON manual_payment_requests (user_id);
+ CREATE INDEX idx_manual_payment_requests_user_id ON manual_payment_requests (user_id);
"""
)
await db.execute(
"""
- CREATE INDEX idx_manual_payment_requests_status
- ON manual_payment_requests (status);
+ CREATE INDEX idx_manual_payment_requests_status ON manual_payment_requests (status);
"""
)
- # =========================================================================
- # BALANCE ASSERTIONS TABLE
- # =========================================================================
- # Reconciliation and balance checking at specific dates
- # Supports multi-currency (satoshis + fiat) with tolerance checking
+async def m005_add_flag_and_meta(db):
+ """
+ Add flag and meta columns to journal_entries table.
+ - flag: Transaction status (* = cleared, ! = pending, # = flagged, x = void)
+ - meta: JSON metadata for audit trail (source, tags, links, notes)
+ """
+ await db.execute(
+ """
+ ALTER TABLE journal_entries ADD COLUMN flag TEXT DEFAULT '*';
+ """
+ )
+
+ await db.execute(
+ """
+ ALTER TABLE journal_entries ADD COLUMN meta TEXT DEFAULT '{}';
+ """
+ )
+
+
+async def m006_hierarchical_account_names(db):
+ """
+ Migrate account names to hierarchical Beancount-style format.
+ - "Cash" → "Assets:Cash"
+ - "Accounts Receivable" → "Assets:Receivable"
+ - "Food & Supplies" → "Expenses:Food:Supplies"
+ - "Accounts Receivable - af983632" → "Assets:Receivable:User-af983632"
+ """
+ from .account_utils import migrate_account_name
+ from .models import AccountType
+
+ # Get all existing accounts
+ accounts = await db.fetchall("SELECT * FROM accounts")
+
+ # Mapping of old names to new names
+ name_mappings = {
+ # Assets
+ "cash": "Assets:Cash",
+ "bank": "Assets:Bank",
+ "lightning": "Assets:Bitcoin:Lightning",
+ "accounts_receivable": "Assets:Receivable",
+
+ # Liabilities
+ "accounts_payable": "Liabilities:Payable",
+
+ # Equity
+ "member_equity": "Equity:MemberEquity",
+ "retained_earnings": "Equity:RetainedEarnings",
+
+ # Revenue → Income
+ "accommodation_revenue": "Income:Accommodation",
+ "service_revenue": "Income:Service",
+ "other_revenue": "Income:Other",
+
+ # Expenses
+ "utilities": "Expenses:Utilities",
+ "food": "Expenses:Food:Supplies",
+ "maintenance": "Expenses:Maintenance",
+ "other_expense": "Expenses:Other",
+ }
+
+ # Update default accounts using ID-based mapping
+ for old_id, new_name in name_mappings.items():
+ await db.execute(
+ """
+ UPDATE accounts
+ SET name = :new_name
+ WHERE id = :old_id
+ """,
+ {"new_name": new_name, "old_id": old_id}
+ )
+
+ # Update user-specific accounts (those with user_id set)
+ user_accounts = await db.fetchall(
+ "SELECT * FROM accounts WHERE user_id IS NOT NULL"
+ )
+
+ for account in user_accounts:
+ # Parse account type
+ account_type = AccountType(account["account_type"])
+
+ # Migrate name
+ new_name = migrate_account_name(account["name"], account_type)
+
+ await db.execute(
+ """
+ UPDATE accounts
+ SET name = :new_name
+ WHERE id = :id
+ """,
+ {"new_name": new_name, "id": account["id"]}
+ )
+
+
+async def m007_balance_assertions(db):
+ """
+ Create balance_assertions table for reconciliation.
+ Allows admins to assert expected balances at specific dates.
+ """
await db.execute(
f"""
CREATE TABLE balance_assertions (
@@ -176,7 +292,6 @@ async def m001_initial(db):
checked_balance_fiat TEXT,
difference_sats INTEGER,
difference_fiat TEXT,
- notes TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_by TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
@@ -188,134 +303,54 @@ async def m001_initial(db):
await db.execute(
"""
- CREATE INDEX idx_balance_assertions_account_id
- ON balance_assertions (account_id);
+ CREATE INDEX idx_balance_assertions_account_id ON balance_assertions (account_id);
"""
)
await db.execute(
"""
- CREATE INDEX idx_balance_assertions_status
- ON balance_assertions (status);
+ CREATE INDEX idx_balance_assertions_status ON balance_assertions (status);
"""
)
await db.execute(
"""
- CREATE INDEX idx_balance_assertions_date
- ON balance_assertions (date);
+ CREATE INDEX idx_balance_assertions_date ON balance_assertions (date);
"""
)
- # =========================================================================
- # USER EQUITY STATUS TABLE
- # =========================================================================
- # Manages equity contribution eligibility for users
- # Equity-eligible users can convert expenses to equity contributions
- # Creates dynamic user-specific equity accounts: Equity:User-{user_id}
-
- await db.execute(
- f"""
- CREATE TABLE user_equity_status (
- user_id TEXT PRIMARY KEY,
- is_equity_eligible BOOLEAN NOT NULL DEFAULT FALSE,
- equity_account_name TEXT,
- notes TEXT,
- granted_by TEXT NOT NULL,
- granted_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
- revoked_at TIMESTAMP
- );
- """
- )
+async def m008_rename_lightning_account(db):
+ """
+ Rename Lightning account from Assets:Lightning:Balance to Assets:Bitcoin:Lightning
+ for better naming consistency.
+ """
await db.execute(
"""
- CREATE INDEX idx_user_equity_status_eligible
- ON user_equity_status (is_equity_eligible)
- WHERE is_equity_eligible = TRUE;
+ UPDATE accounts
+ SET name = 'Assets:Bitcoin:Lightning'
+ WHERE name = 'Assets:Lightning:Balance'
"""
)
- # =========================================================================
- # ACCOUNT PERMISSIONS TABLE
- # =========================================================================
- # Granular access control for accounts
- # Permission types: read, submit_expense, manage
- # Supports hierarchical inheritance (parent account permissions cascade)
-
- await db.execute(
- f"""
- CREATE TABLE account_permissions (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL,
- account_id TEXT NOT NULL,
- permission_type TEXT NOT NULL,
- granted_by TEXT NOT NULL,
- granted_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
- expires_at TIMESTAMP,
- notes TEXT,
- FOREIGN KEY (account_id) REFERENCES accounts (id)
- );
- """
- )
-
- # Index for looking up permissions by user
- await db.execute(
- """
- CREATE INDEX idx_account_permissions_user_id
- ON account_permissions (user_id);
- """
- )
-
- # Index for looking up permissions by account
- await db.execute(
- """
- CREATE INDEX idx_account_permissions_account_id
- ON account_permissions (account_id);
- """
- )
-
- # Composite index for checking specific user+account permissions
- await db.execute(
- """
- CREATE INDEX idx_account_permissions_user_account
- ON account_permissions (user_id, account_id);
- """
- )
-
- # Index for finding permissions by type
- await db.execute(
- """
- CREATE INDEX idx_account_permissions_type
- ON account_permissions (permission_type);
- """
- )
-
- # Index for finding expired permissions
- await db.execute(
- """
- CREATE INDEX idx_account_permissions_expires
- ON account_permissions (expires_at)
- WHERE expires_at IS NOT NULL;
- """
- )
-
- # =========================================================================
- # DEFAULT CHART OF ACCOUNTS
- # =========================================================================
- # Insert comprehensive default accounts with hierarchical names.
- # These accounts cover common use cases and can be extended by admins.
- #
- # Note: User-specific accounts (e.g., Assets:Receivable:User-xxx) are
- # created dynamically when users interact with the system.
- #
- # Note: Equity accounts (Equity:User-xxx) are created dynamically when
- # admins grant equity eligibility to users.
+async def m009_add_onchain_bitcoin_account(db):
+ """
+ Add Assets:Bitcoin:OnChain account for on-chain Bitcoin transactions.
+ This allows tracking on-chain Bitcoin separately from Lightning Network payments.
+ """
import uuid
- from .account_utils import DEFAULT_HIERARCHICAL_ACCOUNTS
- for name, account_type, description in DEFAULT_HIERARCHICAL_ACCOUNTS:
+ # Check if the account already exists
+ existing = await db.fetchone(
+ """
+ SELECT id FROM accounts
+ WHERE name = 'Assets:Bitcoin:OnChain'
+ """
+ )
+
+ if not existing:
+ # Create the on-chain Bitcoin asset account
await db.execute(
f"""
INSERT INTO accounts (id, name, account_type, description, created_at)
@@ -323,275 +358,8 @@ async def m001_initial(db):
""",
{
"id": str(uuid.uuid4()),
- "name": name,
- "type": account_type.value,
- "description": description
+ "name": "Assets:Bitcoin:OnChain",
+ "type": "asset",
+ "description": "On-chain Bitcoin wallet"
}
)
-
-
-async def m002_add_account_is_active(db):
- """
- Add is_active field to accounts table for soft delete functionality.
-
- This enables marking accounts as inactive when they're removed from Beancount
- while preserving historical data and permissions. Inactive accounts:
- - Cannot have new permissions granted
- - Are filtered out of default queries
- - Can be reactivated if account is re-added to Beancount
-
- Default: All existing accounts are marked as active (TRUE).
- """
- await db.execute(
- """
- ALTER TABLE accounts
- ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE
- """
- )
-
- # Create index for faster queries filtering by is_active
- await db.execute(
- """
- CREATE INDEX idx_accounts_is_active ON accounts (is_active)
- """
- )
-
-
-async def m003_add_account_is_virtual(db):
- """
- Add is_virtual field to accounts table for virtual parent accounts.
-
- Virtual parent accounts:
- - Exist only in Castle DB (metadata-only, not in Beancount)
- - Used solely for permission inheritance
- - Allow granting permissions on top-level accounts like "Expenses", "Assets"
- - Are not synced to/from Beancount
- - Cannot be deactivated by account sync (they're intentionally metadata-only)
-
- Use case: Grant permission on "Expenses" → user gets access to all Expenses:* children
-
- Default: All existing accounts are real (is_virtual = FALSE).
- """
- await db.execute(
- """
- ALTER TABLE accounts
- ADD COLUMN is_virtual BOOLEAN NOT NULL DEFAULT FALSE
- """
- )
-
- # Create index for faster queries filtering by is_virtual
- await db.execute(
- """
- CREATE INDEX idx_accounts_is_virtual ON accounts (is_virtual)
- """
- )
-
- # Insert default virtual parent accounts for permission management
- import uuid
-
- virtual_parents = [
- ("Assets", "asset", "All asset accounts"),
- ("Liabilities", "liability", "All liability accounts"),
- ("Equity", "equity", "All equity accounts"),
- ("Income", "revenue", "All income accounts"),
- ("Expenses", "expense", "All expense accounts"),
- ]
-
- for name, account_type, description in virtual_parents:
- await db.execute(
- f"""
- INSERT INTO accounts (id, name, account_type, description, is_active, is_virtual, created_at)
- VALUES (:id, :name, :type, :description, TRUE, TRUE, {db.timestamp_now})
- """,
- {
- "id": str(uuid.uuid4()),
- "name": name,
- "type": account_type,
- "description": description,
- },
- )
-
-
-async def m004_add_rbac_tables(db):
- """
- Add Role-Based Access Control (RBAC) tables.
-
- This migration introduces a flexible RBAC system that complements
- the existing individual permission grants:
-
- - Roles: Named bundles of permissions (Employee, Contractor, Admin, etc.)
- - Role Permissions: Define what accounts each role can access
- - User Roles: Assign users to roles
- - Default Role: Auto-assign new users to a default role
-
- Permission Resolution Order:
- 1. Individual account_permissions (exceptions/overrides)
- 2. Role-based permissions via user_roles
- 3. Inherited permissions (hierarchical account names)
- 4. Deny by default
- """
-
- # =========================================================================
- # ROLES TABLE
- # =========================================================================
- # Define named roles (Employee, Contractor, Admin, etc.)
-
- await db.execute(
- f"""
- CREATE TABLE roles (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL UNIQUE,
- description TEXT,
- is_default BOOLEAN NOT NULL DEFAULT FALSE,
- created_by TEXT NOT NULL,
- created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
- );
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_roles_name ON roles (name);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_roles_is_default ON roles (is_default)
- WHERE is_default = TRUE;
- """
- )
-
- # =========================================================================
- # ROLE PERMISSIONS TABLE
- # =========================================================================
- # Define which accounts each role can access and with what permission type
-
- await db.execute(
- f"""
- CREATE TABLE role_permissions (
- id TEXT PRIMARY KEY,
- role_id TEXT NOT NULL,
- account_id TEXT NOT NULL,
- permission_type TEXT NOT NULL,
- notes TEXT,
- created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
- FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
- FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE
- );
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_role_permissions_role_id ON role_permissions (role_id);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_role_permissions_account_id ON role_permissions (account_id);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_role_permissions_type ON role_permissions (permission_type);
- """
- )
-
- # =========================================================================
- # USER ROLES TABLE
- # =========================================================================
- # Assign users to roles
-
- await db.execute(
- f"""
- CREATE TABLE user_roles (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL,
- role_id TEXT NOT NULL,
- granted_by TEXT NOT NULL,
- granted_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
- expires_at TIMESTAMP,
- notes TEXT,
- FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE
- );
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_user_roles_user_id ON user_roles (user_id);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_user_roles_role_id ON user_roles (role_id);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_user_roles_expires ON user_roles (expires_at)
- WHERE expires_at IS NOT NULL;
- """
- )
-
- # Composite index for checking specific user+role assignments
- await db.execute(
- """
- CREATE INDEX idx_user_roles_user_role ON user_roles (user_id, role_id);
- """
- )
-
- # =========================================================================
- # CREATE DEFAULT ROLES
- # =========================================================================
- # Insert standard roles that most organizations will use
-
- import uuid
-
- # Define default roles and their descriptions
- default_roles = [
- (
- "employee",
- "Employee",
- "Standard employee role with access to common expense accounts",
- True, # This is the default role for new users
- ),
- (
- "contractor",
- "Contractor",
- "External contractor with limited expense account access",
- False,
- ),
- (
- "accountant",
- "Accountant",
- "Accounting staff with read access to financial accounts",
- False,
- ),
- (
- "manager",
- "Manager",
- "Management role with broader expense approval and account access",
- False,
- ),
- ]
-
- for slug, name, description, is_default in default_roles:
- await db.execute(
- f"""
- INSERT INTO roles (id, name, description, is_default, created_by, created_at)
- VALUES (:id, :name, :description, :is_default, :created_by, {db.timestamp_now})
- """,
- {
- "id": str(uuid.uuid4()),
- "name": name,
- "description": description,
- "is_default": is_default,
- "created_by": "system", # System-created default roles
- },
- )
diff --git a/migrations_old.py.bak b/migrations_old.py.bak
deleted file mode 100644
index a412e3e..0000000
--- a/migrations_old.py.bak
+++ /dev/null
@@ -1,651 +0,0 @@
-async def m001_initial(db):
- """
- Initial migration for Castle accounting extension.
- Creates tables for double-entry bookkeeping system.
- """
- await db.execute(
- f"""
- CREATE TABLE accounts (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- account_type TEXT NOT NULL,
- description TEXT,
- user_id TEXT,
- created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
- );
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_accounts_user_id ON accounts (user_id);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_accounts_type ON accounts (account_type);
- """
- )
-
- await db.execute(
- f"""
- CREATE TABLE journal_entries (
- id TEXT PRIMARY KEY,
- description TEXT NOT NULL,
- entry_date TIMESTAMP NOT NULL,
- created_by TEXT NOT NULL,
- created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
- reference TEXT
- );
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_journal_entries_created_by ON journal_entries (created_by);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_journal_entries_date ON journal_entries (entry_date);
- """
- )
-
- await db.execute(
- f"""
- CREATE TABLE entry_lines (
- id TEXT PRIMARY KEY,
- journal_entry_id TEXT NOT NULL,
- account_id TEXT NOT NULL,
- debit INTEGER NOT NULL DEFAULT 0,
- credit INTEGER NOT NULL DEFAULT 0,
- description TEXT,
- metadata TEXT DEFAULT '{{}}'
- );
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_entry_lines_journal_entry ON entry_lines (journal_entry_id);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_entry_lines_account ON entry_lines (account_id);
- """
- )
-
- # Insert default chart of accounts
- default_accounts = [
- # Assets
- ("cash", "Cash", "asset", "Cash on hand"),
- ("bank", "Bank Account", "asset", "Bank account"),
- ("lightning", "Lightning Balance", "asset", "Lightning Network balance"),
- ("accounts_receivable", "Accounts Receivable", "asset", "Money owed to the Castle"),
-
- # Liabilities
- ("accounts_payable", "Accounts Payable", "liability", "Money owed by the Castle"),
-
- # Equity
- ("member_equity", "Member Equity", "equity", "Member contributions"),
- ("retained_earnings", "Retained Earnings", "equity", "Accumulated profits"),
-
- # Revenue
- ("accommodation_revenue", "Accommodation Revenue", "revenue", "Revenue from stays"),
- ("service_revenue", "Service Revenue", "revenue", "Revenue from services"),
- ("other_revenue", "Other Revenue", "revenue", "Other revenue"),
-
- # Expenses
- ("utilities", "Utilities", "expense", "Electricity, water, internet"),
- ("food", "Food & Supplies", "expense", "Food and supplies"),
- ("maintenance", "Maintenance", "expense", "Repairs and maintenance"),
- ("other_expense", "Other Expenses", "expense", "Miscellaneous expenses"),
- ]
-
- for acc_id, name, acc_type, desc in default_accounts:
- await db.execute(
- """
- INSERT INTO accounts (id, name, account_type, description)
- VALUES (:id, :name, :type, :description)
- """,
- {"id": acc_id, "name": name, "type": acc_type, "description": desc}
- )
-
-
-async def m002_extension_settings(db):
- """
- Create extension_settings table for Castle configuration.
- """
- await db.execute(
- f"""
- CREATE TABLE extension_settings (
- id TEXT NOT NULL PRIMARY KEY,
- castle_wallet_id TEXT,
- updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
- );
- """
- )
-
-
-async def m003_user_wallet_settings(db):
- """
- Create user_wallet_settings table for per-user wallet configuration.
- """
- await db.execute(
- f"""
- CREATE TABLE user_wallet_settings (
- id TEXT NOT NULL PRIMARY KEY,
- user_wallet_id TEXT,
- updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
- );
- """
- )
-
-
-async def m004_manual_payment_requests(db):
- """
- Create manual_payment_requests table for user payment requests to Castle.
- """
- await db.execute(
- f"""
- CREATE TABLE manual_payment_requests (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL,
- amount INTEGER NOT NULL,
- description TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'pending',
- created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
- reviewed_at TIMESTAMP,
- reviewed_by TEXT,
- journal_entry_id TEXT
- );
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_manual_payment_requests_user_id ON manual_payment_requests (user_id);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_manual_payment_requests_status ON manual_payment_requests (status);
- """
- )
-
-
-async def m005_add_flag_and_meta(db):
- """
- Add flag and meta columns to journal_entries table.
- - flag: Transaction status (* = cleared, ! = pending, # = flagged, x = void)
- - meta: JSON metadata for audit trail (source, tags, links, notes)
- """
- await db.execute(
- """
- ALTER TABLE journal_entries ADD COLUMN flag TEXT DEFAULT '*';
- """
- )
-
- await db.execute(
- """
- ALTER TABLE journal_entries ADD COLUMN meta TEXT DEFAULT '{}';
- """
- )
-
-
-async def m006_hierarchical_account_names(db):
- """
- Migrate account names to hierarchical Beancount-style format.
- - "Cash" → "Assets:Cash"
- - "Accounts Receivable" → "Assets:Receivable"
- - "Food & Supplies" → "Expenses:Food:Supplies"
- - "Accounts Receivable - af983632" → "Assets:Receivable:User-af983632"
- """
- from .account_utils import migrate_account_name
- from .models import AccountType
-
- # Get all existing accounts
- accounts = await db.fetchall("SELECT * FROM accounts")
-
- # Mapping of old names to new names
- name_mappings = {
- # Assets
- "cash": "Assets:Cash",
- "bank": "Assets:Bank",
- "lightning": "Assets:Bitcoin:Lightning",
- "accounts_receivable": "Assets:Receivable",
-
- # Liabilities
- "accounts_payable": "Liabilities:Payable",
-
- # Equity
- "member_equity": "Equity:MemberEquity",
- "retained_earnings": "Equity:RetainedEarnings",
-
- # Revenue → Income
- "accommodation_revenue": "Income:Accommodation",
- "service_revenue": "Income:Service",
- "other_revenue": "Income:Other",
-
- # Expenses
- "utilities": "Expenses:Utilities",
- "food": "Expenses:Food:Supplies",
- "maintenance": "Expenses:Maintenance",
- "other_expense": "Expenses:Other",
- }
-
- # Update default accounts using ID-based mapping
- for old_id, new_name in name_mappings.items():
- await db.execute(
- """
- UPDATE accounts
- SET name = :new_name
- WHERE id = :old_id
- """,
- {"new_name": new_name, "old_id": old_id}
- )
-
- # Update user-specific accounts (those with user_id set)
- user_accounts = await db.fetchall(
- "SELECT * FROM accounts WHERE user_id IS NOT NULL"
- )
-
- for account in user_accounts:
- # Parse account type
- account_type = AccountType(account["account_type"])
-
- # Migrate name
- new_name = migrate_account_name(account["name"], account_type)
-
- await db.execute(
- """
- UPDATE accounts
- SET name = :new_name
- WHERE id = :id
- """,
- {"new_name": new_name, "id": account["id"]}
- )
-
-
-async def m007_balance_assertions(db):
- """
- Create balance_assertions table for reconciliation.
- Allows admins to assert expected balances at specific dates.
- """
- await db.execute(
- f"""
- CREATE TABLE balance_assertions (
- id TEXT PRIMARY KEY,
- date TIMESTAMP NOT NULL,
- account_id TEXT NOT NULL,
- expected_balance_sats INTEGER NOT NULL,
- expected_balance_fiat TEXT,
- fiat_currency TEXT,
- tolerance_sats INTEGER DEFAULT 0,
- tolerance_fiat TEXT DEFAULT '0',
- checked_balance_sats INTEGER,
- checked_balance_fiat TEXT,
- difference_sats INTEGER,
- difference_fiat TEXT,
- status TEXT NOT NULL DEFAULT 'pending',
- created_by TEXT NOT NULL,
- created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
- checked_at TIMESTAMP,
- FOREIGN KEY (account_id) REFERENCES accounts (id)
- );
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_balance_assertions_account_id ON balance_assertions (account_id);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_balance_assertions_status ON balance_assertions (status);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_balance_assertions_date ON balance_assertions (date);
- """
- )
-
-
-async def m008_rename_lightning_account(db):
- """
- Rename Lightning account from Assets:Lightning:Balance to Assets:Bitcoin:Lightning
- for better naming consistency.
- """
- await db.execute(
- """
- UPDATE accounts
- SET name = 'Assets:Bitcoin:Lightning'
- WHERE name = 'Assets:Lightning:Balance'
- """
- )
-
-
-async def m009_add_onchain_bitcoin_account(db):
- """
- Add Assets:Bitcoin:OnChain account for on-chain Bitcoin transactions.
- This allows tracking on-chain Bitcoin separately from Lightning Network payments.
- """
- import uuid
-
- # Check if the account already exists
- existing = await db.fetchone(
- """
- SELECT id FROM accounts
- WHERE name = 'Assets:Bitcoin:OnChain'
- """
- )
-
- if not existing:
- # Create the on-chain Bitcoin asset account
- await db.execute(
- f"""
- INSERT INTO accounts (id, name, account_type, description, created_at)
- VALUES (:id, :name, :type, :description, {db.timestamp_now})
- """,
- {
- "id": str(uuid.uuid4()),
- "name": "Assets:Bitcoin:OnChain",
- "type": "asset",
- "description": "On-chain Bitcoin wallet"
- }
- )
-
-
-async def m010_user_equity_status(db):
- """
- Create user_equity_status table for managing equity contribution eligibility.
- Only equity-eligible users can convert their expenses to equity contributions.
- """
- await db.execute(
- f"""
- CREATE TABLE user_equity_status (
- user_id TEXT PRIMARY KEY,
- is_equity_eligible BOOLEAN NOT NULL DEFAULT FALSE,
- equity_account_name TEXT,
- notes TEXT,
- granted_by TEXT NOT NULL,
- granted_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
- revoked_at TIMESTAMP
- );
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_user_equity_status_eligible
- ON user_equity_status (is_equity_eligible)
- WHERE is_equity_eligible = TRUE;
- """
- )
-
-
-async def m011_account_permissions(db):
- """
- Create account_permissions table for granular account access control.
- Allows admins to grant specific permissions (read, submit_expense, manage) to users for specific accounts.
- Supports hierarchical permission inheritance (permissions on parent accounts cascade to children).
- """
- await db.execute(
- f"""
- CREATE TABLE account_permissions (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL,
- account_id TEXT NOT NULL,
- permission_type TEXT NOT NULL,
- granted_by TEXT NOT NULL,
- granted_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
- expires_at TIMESTAMP,
- notes TEXT,
- FOREIGN KEY (account_id) REFERENCES accounts (id)
- );
- """
- )
-
- # Index for looking up permissions by user
- await db.execute(
- """
- CREATE INDEX idx_account_permissions_user_id ON account_permissions (user_id);
- """
- )
-
- # Index for looking up permissions by account
- await db.execute(
- """
- CREATE INDEX idx_account_permissions_account_id ON account_permissions (account_id);
- """
- )
-
- # Composite index for checking specific user+account permissions
- await db.execute(
- """
- CREATE INDEX idx_account_permissions_user_account
- ON account_permissions (user_id, account_id);
- """
- )
-
- # Index for finding permissions by type
- await db.execute(
- """
- CREATE INDEX idx_account_permissions_type ON account_permissions (permission_type);
- """
- )
-
- # Index for finding expired permissions
- await db.execute(
- """
- CREATE INDEX idx_account_permissions_expires
- ON account_permissions (expires_at)
- WHERE expires_at IS NOT NULL;
- """
- )
-
-
-async def m012_update_default_accounts(db):
- """
- Update default chart of accounts to include more detailed hierarchical structure.
- Adds new accounts for fixed assets, livestock, equity contributions, and detailed expenses.
- Only adds accounts that don't already exist.
- """
- import uuid
- from .account_utils import DEFAULT_HIERARCHICAL_ACCOUNTS
-
- for name, account_type, description in DEFAULT_HIERARCHICAL_ACCOUNTS:
- # Check if account already exists
- existing = await db.fetchone(
- """
- SELECT id FROM accounts WHERE name = :name
- """,
- {"name": name}
- )
-
- if not existing:
- # Create new account
- await db.execute(
- f"""
- INSERT INTO accounts (id, name, account_type, description, created_at)
- VALUES (:id, :name, :type, :description, {db.timestamp_now})
- """,
- {
- "id": str(uuid.uuid4()),
- "name": name,
- "type": account_type.value,
- "description": description
- }
- )
-
-
-async def m013_remove_parent_only_accounts(db):
- """
- Remove parent-only accounts from the database.
-
- Since Castle doesn't interface directly with Beancount (only exports to it),
- we don't need parent accounts that exist only for organizational hierarchy.
- The hierarchy is implicit in the colon-separated account names.
-
- When exporting to Beancount, the parent accounts will be inferred from the
- hierarchical naming (e.g., "Assets:Bitcoin:Lightning" implies "Assets:Bitcoin" exists).
-
- This keeps our database clean and prevents accidentally posting to parent accounts.
-
- Removes:
- - Assets:Bitcoin (parent of Lightning and OnChain)
- - Equity (parent of user equity accounts like Equity:User-xxx)
- """
- # Remove Assets:Bitcoin (parent account)
- await db.execute(
- "DELETE FROM accounts WHERE name = :name",
- {"name": "Assets:Bitcoin"}
- )
-
- # Remove Equity (parent account)
- await db.execute(
- "DELETE FROM accounts WHERE name = :name",
- {"name": "Equity"}
- )
-
-
-async def m014_remove_legacy_equity_accounts(db):
- """
- Remove legacy generic equity accounts that don't fit the user-specific equity model.
-
- The castle extension uses dynamic user-specific equity accounts (Equity:User-{user_id})
- created automatically when granting equity eligibility. Generic equity accounts like
- MemberEquity and RetainedEarnings are not needed.
-
- Removes:
- - Equity:MemberEquity
- - Equity:RetainedEarnings
- """
- # Remove Equity:MemberEquity
- await db.execute(
- "DELETE FROM accounts WHERE name = :name",
- {"name": "Equity:MemberEquity"}
- )
-
- # Remove Equity:RetainedEarnings
- await db.execute(
- "DELETE FROM accounts WHERE name = :name",
- {"name": "Equity:RetainedEarnings"}
- )
-
-
-async def m015_convert_to_single_amount_field(db):
- """
- Convert entry_lines from separate debit/credit columns to single amount field.
-
- This aligns Castle with Beancount's elegant design:
- - Positive amount = debit (increase assets/expenses, decrease liabilities/equity/revenue)
- - Negative amount = credit (decrease assets/expenses, increase liabilities/equity/revenue)
-
- Benefits:
- - Simpler model (one field instead of two)
- - Direct compatibility with Beancount import/export
- - Eliminates invalid states (both debit and credit non-zero)
- - More intuitive for programmers (positive/negative instead of accounting conventions)
-
- Migration formula: amount = debit - credit
-
- Examples:
- - Expense transaction:
- * Expenses:Food:Groceries amount=+100 (debit)
- * Liabilities:Payable:User amount=-100 (credit)
- - Payment transaction:
- * Liabilities:Payable:User amount=+100 (debit)
- * Assets:Bitcoin:Lightning amount=-100 (credit)
- """
- from sqlalchemy.exc import OperationalError
-
- # Step 1: Add new amount column (nullable for migration)
- try:
- await db.execute(
- "ALTER TABLE entry_lines ADD COLUMN amount INTEGER"
- )
- except OperationalError:
- # Column might already exist if migration was partially run
- pass
-
- # Step 2: Populate amount from existing debit/credit
- # Formula: amount = debit - credit
- await db.execute(
- """
- UPDATE entry_lines
- SET amount = debit - credit
- WHERE amount IS NULL
- """
- )
-
- # Step 3: Create new table with amount field as NOT NULL
- # SQLite doesn't support ALTER COLUMN, so we need to recreate the table
- await db.execute(
- """
- CREATE TABLE entry_lines_new (
- id TEXT PRIMARY KEY,
- journal_entry_id TEXT NOT NULL,
- account_id TEXT NOT NULL,
- amount INTEGER NOT NULL,
- description TEXT,
- metadata TEXT DEFAULT '{}'
- )
- """
- )
-
- # Step 4: Copy data from old table to new
- await db.execute(
- """
- INSERT INTO entry_lines_new (id, journal_entry_id, account_id, amount, description, metadata)
- SELECT id, journal_entry_id, account_id, amount, description, metadata
- FROM entry_lines
- """
- )
-
- # Step 5: Drop old table and rename new one
- await db.execute("DROP TABLE entry_lines")
- await db.execute("ALTER TABLE entry_lines_new RENAME TO entry_lines")
-
- # Step 6: Recreate indexes
- await db.execute(
- """
- CREATE INDEX idx_entry_lines_journal_entry ON entry_lines (journal_entry_id)
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_entry_lines_account ON entry_lines (account_id)
- """
- )
-
-
-async def m016_drop_obsolete_journal_tables(db):
- """
- Drop journal_entries and entry_lines tables.
-
- Castle now uses Fava/Beancount as the single source of truth for accounting data.
- These tables are no longer written to or read from.
-
- All journal entry operations now:
- - Write: Submit to Fava via FavaClient.add_entry()
- - Read: Query Fava via FavaClient.get_entries()
-
- Migration completed as part of Castle extension cleanup (Nov 2025).
- No backwards compatibility concerns - user explicitly approved.
- """
- # Drop entry_lines first (has foreign key to journal_entries)
- await db.execute("DROP TABLE IF EXISTS entry_lines")
-
- # Drop journal_entries
- await db.execute("DROP TABLE IF EXISTS journal_entries")
diff --git a/models.py b/models.py
index 5199b6d..ffde1c6 100644
--- a/models.py
+++ b/models.py
@@ -15,18 +15,11 @@ class AccountType(str, Enum):
class JournalEntryFlag(str, Enum):
- """Transaction status flags (Beancount-compatible)
-
- Beancount only supports two user-facing flags:
- - * (CLEARED): Completed transactions
- - ! (PENDING): Transactions needing attention
-
- For voided/flagged transactions, use tags instead:
- - Voided: Use "!" flag + #voided tag
- - Flagged: Use "!" flag + #review tag
- """
+ """Transaction status flags (Beancount-style)"""
CLEARED = "*" # Fully reconciled/confirmed
PENDING = "!" # Not yet confirmed/awaiting approval
+ FLAGGED = "#" # Needs review/attention
+ VOID = "x" # Voided/cancelled entry
class Account(BaseModel):
@@ -36,8 +29,6 @@ class Account(BaseModel):
description: Optional[str] = None
user_id: Optional[str] = None # For user-specific accounts
created_at: datetime
- is_active: bool = True # Soft delete flag
- is_virtual: bool = False # Virtual parent account (metadata-only, not in Beancount)
class CreateAccount(BaseModel):
@@ -45,21 +36,22 @@ class CreateAccount(BaseModel):
account_type: AccountType
description: Optional[str] = None
user_id: Optional[str] = None
- is_virtual: bool = False # Set to True to create virtual parent account
class EntryLine(BaseModel):
id: str
journal_entry_id: str
account_id: str
- amount: int # in satoshis; positive = debit, negative = credit
+ debit: int = 0 # in satoshis
+ credit: int = 0 # in satoshis
description: Optional[str] = None
metadata: dict = {} # Stores currency info: fiat_currency, fiat_amount, fiat_rate, etc.
class CreateEntryLine(BaseModel):
account_id: str
- amount: int # in satoshis; positive = debit, negative = credit
+ debit: int = 0
+ credit: int = 0
description: Optional[str] = None
metadata: dict = {} # Stores currency info
@@ -131,12 +123,6 @@ class CastleSettings(BaseModel):
"""Settings for the Castle extension"""
castle_wallet_id: Optional[str] = None # The wallet ID that represents the Castle
-
- # Fava/Beancount integration - ALL accounting is done via Fava
- fava_url: str = "http://localhost:3333" # Base URL of Fava server
- fava_ledger_slug: str = "castle-ledger" # Ledger identifier in Fava URL
- fava_timeout: float = 10.0 # Request timeout in seconds
-
updated_at: datetime = Field(default_factory=lambda: datetime.now())
@classmethod
@@ -261,172 +247,3 @@ class CreateBalanceAssertion(BaseModel):
fiat_currency: Optional[str] = None
tolerance_sats: int = 0
tolerance_fiat: Decimal = Decimal("0")
-
-
-class UserEquityStatus(BaseModel):
- """Tracks user's equity eligibility and status"""
- user_id: str # User's wallet ID
- is_equity_eligible: bool # Can user convert expenses to equity?
- equity_account_name: Optional[str] = None # e.g., "Equity:Alice"
- notes: Optional[str] = None # Admin notes
- granted_by: str # Admin who granted eligibility
- granted_at: datetime
- revoked_at: Optional[datetime] = None # If eligibility was revoked
-
-
-class CreateUserEquityStatus(BaseModel):
- """Create or update user equity eligibility"""
- user_id: str
- is_equity_eligible: bool
- equity_account_name: Optional[str] = None # Auto-generated as "Equity:User-{user_id}" if not provided
- notes: Optional[str] = None
-
-
-class UserInfo(BaseModel):
- """User information including equity eligibility"""
- user_id: str
- is_equity_eligible: bool
- equity_account_name: Optional[str] = None
-
-
-class PermissionType(str, Enum):
- """Types of permissions for account access"""
- READ = "read" # Can view account and its balance
- SUBMIT_EXPENSE = "submit_expense" # Can submit expenses to this account
- MANAGE = "manage" # Can modify account (admin level)
-
-
-class AccountPermission(BaseModel):
- """Defines which accounts a user can access"""
- id: str # Unique permission ID
- user_id: str # User's wallet ID (from invoice key)
- account_id: str # Account ID from accounts table
- permission_type: PermissionType
- granted_by: str # Admin user ID who granted permission
- granted_at: datetime
- expires_at: Optional[datetime] = None # Optional expiration
- notes: Optional[str] = None # Admin notes about this permission
-
-
-class CreateAccountPermission(BaseModel):
- """Create account permission"""
- user_id: str
- account_id: str
- permission_type: PermissionType
- expires_at: Optional[datetime] = None
- notes: Optional[str] = None
-
-
-class BulkGrantPermission(BaseModel):
- """Bulk grant same permission to multiple users"""
- user_ids: list[str] # List of user IDs to grant permission to
- account_id: str # Account to grant permission on
- permission_type: PermissionType # Type of permission to grant
- expires_at: Optional[datetime] = None # Optional expiration
- notes: Optional[str] = None # Notes for all permissions
-
-
-class BulkGrantResult(BaseModel):
- """Result of bulk grant operation"""
- granted: list[AccountPermission] # Successfully granted permissions
- failed: list[dict] # Failed grants with errors
- total: int # Total attempted
- success_count: int # Number of successful grants
- failure_count: int # Number of failed grants
-
-
-class AccountWithPermissions(BaseModel):
- """Account with user-specific permission metadata"""
- id: str
- name: str
- account_type: AccountType
- description: Optional[str] = None
- user_id: Optional[str] = None
- created_at: datetime
- is_active: bool = True # Soft delete flag
- is_virtual: bool = False # Virtual parent account (metadata-only)
- # Only included when filter_by_user=true
- user_permissions: Optional[list[PermissionType]] = None
- inherited_from: Optional[str] = None # Parent account ID if inherited
- # Hierarchical structure
- parent_account: Optional[str] = None # Parent account name
- level: Optional[int] = None # Depth in hierarchy (0 = top level)
- has_children: Optional[bool] = None # Whether this account has sub-accounts
-
-
-# ===== ROLE-BASED ACCESS CONTROL (RBAC) MODELS =====
-
-
-class Role(BaseModel):
- """Role definition for RBAC system"""
- id: str
- name: str # Display name (e.g., "Employee", "Contractor")
- description: Optional[str] = None
- is_default: bool = False # Auto-assign this role to new users
- created_by: str # User ID who created the role
- created_at: datetime
-
-
-class CreateRole(BaseModel):
- """Create a new role"""
- name: str
- description: Optional[str] = None
- is_default: bool = False
-
-
-class UpdateRole(BaseModel):
- """Update an existing role"""
- name: Optional[str] = None
- description: Optional[str] = None
- is_default: Optional[bool] = None
-
-
-class RolePermission(BaseModel):
- """Permission granted to a role for a specific account"""
- id: str
- role_id: str
- account_id: str
- permission_type: PermissionType
- notes: Optional[str] = None
- created_at: datetime
-
-
-class CreateRolePermission(BaseModel):
- """Create a permission for a role"""
- role_id: str
- account_id: str
- permission_type: PermissionType
- notes: Optional[str] = None
-
-
-class UserRole(BaseModel):
- """Assignment of a user to a role"""
- id: str
- user_id: str # User's wallet ID
- role_id: str
- granted_by: str # Admin who assigned the role
- granted_at: datetime
- expires_at: Optional[datetime] = None
- notes: Optional[str] = None
-
-
-class AssignUserRole(BaseModel):
- """Assign a user to a role"""
- user_id: str
- role_id: str
- expires_at: Optional[datetime] = None
- notes: Optional[str] = None
-
-
-class RoleWithPermissions(BaseModel):
- """Role with its associated permissions and user count"""
- role: Role
- permissions: list[RolePermission]
- user_count: int # Number of users assigned to this role
-
-
-class UserWithRoles(BaseModel):
- """User information with their assigned roles"""
- user_id: str
- roles: list[Role]
- direct_permissions: list[AccountPermission] # Individual permissions not from roles
diff --git a/package.json b/package.json
deleted file mode 100644
index f479115..0000000
--- a/package.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "name": "castle",
- "version": "0.0.2",
- "description": "Accounting for a collective entity",
- "main": "index.js",
- "scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
- },
- "author": "",
- "license": "ISC",
- "dependencies": {
- "prettier": "^3.2.5",
- "pyright": "^1.1.358"
- }
-}
diff --git a/permission_management.py b/permission_management.py
deleted file mode 100644
index 7dea217..0000000
--- a/permission_management.py
+++ /dev/null
@@ -1,475 +0,0 @@
-"""
-Bulk Permission Management Module
-
-Provides convenience functions for managing permissions at scale.
-
-Features:
-- Bulk grant to multiple users
-- Bulk revoke operations
-- Permission templates/copying
-- User offboarding
-- Permission analytics
-
-Related: PERMISSIONS-SYSTEM.md - Improvement Opportunity #3
-"""
-
-from datetime import datetime
-from typing import Optional
-from loguru import logger
-
-from .crud import (
- create_account_permission,
- delete_account_permission,
- get_account_permissions,
- get_user_permissions,
- get_account,
-)
-from .models import (
- AccountPermission,
- CreateAccountPermission,
- PermissionType,
-)
-
-
-async def bulk_grant_permission(
- user_ids: list[str],
- account_id: str,
- permission_type: PermissionType,
- granted_by: str,
- expires_at: Optional[datetime] = None,
- notes: Optional[str] = None,
-) -> dict:
- """
- Grant the same permission to multiple users.
-
- Args:
- user_ids: List of user IDs to grant permission to
- account_id: Account to grant permission on
- permission_type: Type of permission (READ, SUBMIT_EXPENSE, MANAGE)
- granted_by: Admin user ID granting the permission
- expires_at: Optional expiration date
- notes: Optional notes about this bulk grant
-
- Returns:
- dict with results:
- {
- "granted": 15,
- "failed": 2,
- "errors": ["user123: Already has permission", ...],
- "permissions": [permission_obj, ...]
- }
-
- Example:
- # Grant submit_expense to all food team members
- await bulk_grant_permission(
- user_ids=["alice", "bob", "charlie"],
- account_id="expenses_food_id",
- permission_type=PermissionType.SUBMIT_EXPENSE,
- granted_by="admin",
- expires_at=datetime(2025, 12, 31),
- notes="Q4 food team members"
- )
- """
- logger.info(
- f"Bulk granting {permission_type.value} permission to {len(user_ids)} users on account {account_id}"
- )
-
- # Verify account exists
- account = await get_account(account_id)
- if not account:
- return {
- "granted": 0,
- "failed": len(user_ids),
- "errors": [f"Account {account_id} not found"],
- "permissions": [],
- }
-
- granted = 0
- failed = 0
- errors = []
- permissions = []
-
- for user_id in user_ids:
- try:
- permission = await create_account_permission(
- data=CreateAccountPermission(
- user_id=user_id,
- account_id=account_id,
- permission_type=permission_type,
- expires_at=expires_at,
- notes=notes,
- ),
- granted_by=granted_by,
- )
-
- permissions.append(permission)
- granted += 1
- logger.debug(f"Granted {permission_type.value} to {user_id} on {account.name}")
-
- except Exception as e:
- failed += 1
- error_msg = f"{user_id}: {str(e)}"
- errors.append(error_msg)
- logger.warning(f"Failed to grant permission to {user_id}: {e}")
-
- logger.info(
- f"Bulk grant complete: {granted} granted, {failed} failed on account {account.name}"
- )
-
- return {
- "granted": granted,
- "failed": failed,
- "errors": errors,
- "permissions": permissions,
- }
-
-
-async def revoke_all_user_permissions(user_id: str) -> dict:
- """
- Revoke ALL permissions for a user (offboarding).
-
- Args:
- user_id: User ID to revoke all permissions from
-
- Returns:
- dict with results:
- {
- "revoked": 5,
- "failed": 0,
- "errors": [],
- "permission_types_removed": ["read", "submit_expense"]
- }
-
- Example:
- # Remove all access when user leaves
- await revoke_all_user_permissions("departed_user")
- """
- logger.info(f"Revoking ALL permissions for user {user_id}")
-
- permissions = await get_user_permissions(user_id)
-
- revoked = 0
- failed = 0
- errors = []
- permission_types = set()
-
- for perm in permissions:
- try:
- await delete_account_permission(perm.id)
- revoked += 1
- permission_types.add(perm.permission_type.value)
- logger.debug(f"Revoked {perm.permission_type.value} from {user_id}")
-
- except Exception as e:
- failed += 1
- error_msg = f"{perm.id}: {str(e)}"
- errors.append(error_msg)
- logger.warning(f"Failed to revoke permission {perm.id}: {e}")
-
- logger.info(f"User offboarding complete: {revoked} permissions revoked for {user_id}")
-
- return {
- "revoked": revoked,
- "failed": failed,
- "errors": errors,
- "permission_types_removed": sorted(list(permission_types)),
- }
-
-
-async def revoke_all_permissions_on_account(account_id: str) -> dict:
- """
- Revoke ALL permissions on an account (account closure).
-
- Args:
- account_id: Account ID to revoke all permissions from
-
- Returns:
- dict with results:
- {
- "revoked": 8,
- "failed": 0,
- "errors": [],
- "users_affected": ["alice", "bob", "charlie"]
- }
-
- Example:
- # Close project and remove all access
- await revoke_all_permissions_on_account("old_project_id")
- """
- logger.info(f"Revoking ALL permissions on account {account_id}")
-
- permissions = await get_account_permissions(account_id)
-
- revoked = 0
- failed = 0
- errors = []
- users_affected = set()
-
- for perm in permissions:
- try:
- await delete_account_permission(perm.id)
- revoked += 1
- users_affected.add(perm.user_id)
- logger.debug(f"Revoked permission from {perm.user_id} on account")
-
- except Exception as e:
- failed += 1
- error_msg = f"{perm.id}: {str(e)}"
- errors.append(error_msg)
- logger.warning(f"Failed to revoke permission {perm.id}: {e}")
-
- logger.info(f"Account closure complete: {revoked} permissions revoked")
-
- return {
- "revoked": revoked,
- "failed": failed,
- "errors": errors,
- "users_affected": sorted(list(users_affected)),
- }
-
-
-async def copy_permissions(
- from_user_id: str,
- to_user_id: str,
- granted_by: str,
- permission_types: Optional[list[PermissionType]] = None,
- notes: Optional[str] = None,
-) -> dict:
- """
- Copy all permissions from one user to another (permission template).
-
- Args:
- from_user_id: User to copy permissions from
- to_user_id: User to copy permissions to
- granted_by: Admin granting the new permissions
- permission_types: Optional filter - only copy specific permission types
- notes: Optional notes for the copied permissions
-
- Returns:
- dict with results:
- {
- "copied": 5,
- "failed": 0,
- "errors": [],
- "permissions": [permission_obj, ...]
- }
-
- Example:
- # Copy all submit_expense permissions from experienced user
- await copy_permissions(
- from_user_id="alice",
- to_user_id="bob",
- granted_by="admin",
- permission_types=[PermissionType.SUBMIT_EXPENSE],
- notes="Copied from Alice - new food coordinator"
- )
- """
- logger.info(f"Copying permissions from {from_user_id} to {to_user_id}")
-
- # Get source user's permissions
- source_permissions = await get_user_permissions(from_user_id)
-
- # Filter by permission type if specified
- if permission_types:
- source_permissions = [
- p for p in source_permissions if p.permission_type in permission_types
- ]
-
- copied = 0
- failed = 0
- errors = []
- permissions = []
-
- for source_perm in source_permissions:
- try:
- # Create new permission for target user
- new_permission = await create_account_permission(
- data=CreateAccountPermission(
- user_id=to_user_id,
- account_id=source_perm.account_id,
- permission_type=source_perm.permission_type,
- expires_at=source_perm.expires_at, # Copy expiration
- notes=notes or f"Copied from {from_user_id}",
- ),
- granted_by=granted_by,
- )
-
- permissions.append(new_permission)
- copied += 1
- logger.debug(
- f"Copied {source_perm.permission_type.value} permission to {to_user_id}"
- )
-
- except Exception as e:
- failed += 1
- error_msg = f"{source_perm.id}: {str(e)}"
- errors.append(error_msg)
- logger.warning(f"Failed to copy permission {source_perm.id}: {e}")
-
- logger.info(f"Permission copy complete: {copied} copied, {failed} failed")
-
- return {
- "copied": copied,
- "failed": failed,
- "errors": errors,
- "permissions": permissions,
- }
-
-
-async def get_permission_analytics() -> dict:
- """
- Get analytics about permission usage (for admin dashboard).
-
- Returns:
- dict with analytics:
- {
- "total_permissions": 150,
- "by_type": {"read": 50, "submit_expense": 80, "manage": 20},
- "expiring_soon": [...], # Expire in next 7 days
- "expired": [...], # Already expired but not cleaned up
- "users_with_permissions": 45,
- "users_without_permissions": ["bob", ...],
- "most_permissioned_accounts": [...]
- }
-
- Example:
- stats = await get_permission_analytics()
- print(f"Total permissions: {stats['total_permissions']}")
- """
- from datetime import timedelta
- from . import db
-
- logger.debug("Gathering permission analytics")
-
- # Total permissions
- total_result = await db.fetchone("SELECT COUNT(*) as count FROM account_permissions")
- total_permissions = total_result["count"] if total_result else 0
-
- # By type
- type_result = await db.fetchall(
- """
- SELECT permission_type, COUNT(*) as count
- FROM account_permissions
- GROUP BY permission_type
- """
- )
- by_type = {row["permission_type"]: row["count"] for row in type_result}
-
- # Expiring soon (next 7 days)
- seven_days_from_now = datetime.now() + timedelta(days=7)
- expiring_result = await db.fetchall(
- """
- SELECT ap.*, a.name as account_name
- FROM account_permissions ap
- JOIN castle_accounts a ON ap.account_id = a.id
- WHERE ap.expires_at IS NOT NULL
- AND ap.expires_at > :now
- AND ap.expires_at <= :seven_days
- ORDER BY ap.expires_at ASC
- LIMIT 20
- """,
- {"now": datetime.now(), "seven_days": seven_days_from_now},
- )
-
- expiring_soon = [
- {
- "user_id": row["user_id"],
- "account_name": row["account_name"],
- "permission_type": row["permission_type"],
- "expires_at": row["expires_at"],
- }
- for row in expiring_result
- ]
-
- # Most permissioned accounts
- top_accounts_result = await db.fetchall(
- """
- SELECT a.name, COUNT(ap.id) as permission_count
- FROM castle_accounts a
- LEFT JOIN account_permissions ap ON a.id = ap.account_id
- GROUP BY a.id, a.name
- HAVING COUNT(ap.id) > 0
- ORDER BY permission_count DESC
- LIMIT 10
- """
- )
-
- most_permissioned_accounts = [
- {"account": row["name"], "permission_count": row["permission_count"]}
- for row in top_accounts_result
- ]
-
- # Unique users with permissions
- users_result = await db.fetchone(
- "SELECT COUNT(DISTINCT user_id) as count FROM account_permissions"
- )
- users_with_permissions = users_result["count"] if users_result else 0
-
- return {
- "total_permissions": total_permissions,
- "by_type": by_type,
- "expiring_soon": expiring_soon,
- "users_with_permissions": users_with_permissions,
- "most_permissioned_accounts": most_permissioned_accounts,
- }
-
-
-async def cleanup_expired_permissions(days_old: int = 30) -> dict:
- """
- Clean up permissions that expired more than N days ago.
-
- Args:
- days_old: Delete permissions expired this many days ago
-
- Returns:
- dict with results:
- {
- "deleted": 15,
- "errors": []
- }
-
- Example:
- # Delete permissions expired more than 30 days ago
- await cleanup_expired_permissions(days_old=30)
- """
- from datetime import timedelta
- from . import db
-
- logger.info(f"Cleaning up permissions expired more than {days_old} days ago")
-
- cutoff_date = datetime.now() - timedelta(days=days_old)
-
- try:
- result = await db.execute(
- """
- DELETE FROM account_permissions
- WHERE expires_at IS NOT NULL
- AND expires_at < :cutoff_date
- """,
- {"cutoff_date": cutoff_date},
- )
-
- # SQLite doesn't return rowcount reliably, so count before delete
- count_result = await db.fetchone(
- """
- SELECT COUNT(*) as count FROM account_permissions
- WHERE expires_at IS NOT NULL
- AND expires_at < :cutoff_date
- """,
- {"cutoff_date": cutoff_date},
- )
- deleted = count_result["count"] if count_result else 0
-
- logger.info(f"Cleaned up {deleted} expired permissions")
-
- return {
- "deleted": deleted,
- "errors": [],
- }
-
- except Exception as e:
- logger.error(f"Failed to cleanup expired permissions: {e}")
- return {
- "deleted": 0,
- "errors": [str(e)],
- }
diff --git a/services.py b/services.py
index 1f9d826..47a3d7b 100644
--- a/services.py
+++ b/services.py
@@ -2,12 +2,11 @@ from .crud import (
create_castle_settings,
create_user_wallet_settings,
get_castle_settings,
- get_or_create_user_account,
get_user_wallet_settings,
update_castle_settings,
update_user_wallet_settings,
)
-from .models import AccountType, CastleSettings, UserWalletSettings
+from .models import CastleSettings, UserWalletSettings
async def get_settings(user_id: str) -> CastleSettings:
@@ -37,28 +36,10 @@ async def get_user_wallet(user_id: str) -> UserWalletSettings:
async def update_user_wallet(
user_id: str, data: UserWalletSettings
) -> UserWalletSettings:
- from loguru import logger
-
- logger.info(f"[WALLET UPDATE] Starting update_user_wallet for user {user_id[:8]}")
-
settings = await get_user_wallet_settings(user_id)
if not settings:
- logger.info(f"[WALLET UPDATE] Creating new wallet settings for user {user_id[:8]}")
settings = await create_user_wallet_settings(user_id, data)
else:
- logger.info(f"[WALLET UPDATE] Updating existing wallet settings for user {user_id[:8]}")
settings = await update_user_wallet_settings(user_id, data)
- # Proactively create core user accounts when wallet is configured
- # This ensures all users have a consistent account structure from the start
- logger.info(f"[WALLET UPDATE] Creating LIABILITY account for user {user_id[:8]}")
- await get_or_create_user_account(
- user_id, AccountType.LIABILITY, "Accounts Payable"
- )
- logger.info(f"[WALLET UPDATE] Creating ASSET account for user {user_id[:8]}")
- await get_or_create_user_account(
- user_id, AccountType.ASSET, "Accounts Receivable"
- )
- logger.info(f"[WALLET UPDATE] Completed update_user_wallet for user {user_id[:8]}")
-
return settings
diff --git a/static/js/index.js b/static/js/index.js
index 318483b..2517657 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -3,32 +3,18 @@ const mapJournalEntry = obj => {
}
window.app = Vue.createApp({
+ el: '#vue',
mixins: [windowMixin],
data() {
return {
balance: null,
allUserBalances: [],
transactions: [],
- transactionPagination: {
- total: 0,
- limit: 10,
- offset: 0,
- has_next: false,
- has_prev: false
- },
- transactionFilter: {
- user_id: null, // For filtering by user
- account_type: null, // For filtering by receivable/payable (asset/liability)
- dateRangeType: 15, // Preset days (15, 30, 60) or 'custom'
- startDate: null, // For custom date range (YYYY-MM-DD)
- endDate: null // For custom date range (YYYY-MM-DD)
- },
accounts: [],
currencies: [],
users: [],
settings: null,
userWalletSettings: null,
- userInfo: null, // User information including equity eligibility
isAdmin: false,
isSuperUser: false,
castleWalletConfigured: false,
@@ -189,25 +175,6 @@ window.app = Vue.createApp({
}
},
computed: {
- transactionColumns() {
- return [
- { name: 'flag', label: 'Status', field: 'flag', align: 'left', sortable: true },
- { name: 'username', label: 'User', field: 'username', align: 'left', sortable: true },
- { name: 'date', label: 'Date', field: 'entry_date', align: 'left', sortable: true },
- { name: 'description', label: 'Description', field: 'description', align: 'left', sortable: false },
- { name: 'amount', label: 'Amount (sats)', field: 'amount', align: 'right', sortable: false },
- { name: 'fiat', label: 'Fiat Amount', field: 'fiat', align: 'right', sortable: false },
- { name: 'reference', label: 'Reference', field: 'reference', align: 'left', sortable: false }
- ]
- },
- accountTypeOptions() {
- return [
- { label: 'All Types', value: null },
- { label: 'Receivable (User owes Castle)', value: 'asset' },
- { label: 'Payable (Castle owes User)', value: 'liability' },
- { label: 'Equity (User Balance)', value: 'equity' }
- ]
- },
expenseAccounts() {
return this.accounts.filter(a => a.account_type === 'expense')
},
@@ -324,12 +291,6 @@ window.app = Vue.createApp({
}
} catch (error) {
LNbits.utils.notifyApiError(error)
- // Set default balance to clear loading state
- this.balance = {
- balance: 0,
- fiat_balances: {},
- accounts: []
- }
}
},
async loadAllUserBalances() {
@@ -344,123 +305,28 @@ window.app = Vue.createApp({
console.error('Error loading all user balances:', error)
}
},
- async loadTransactions(offset = null) {
+ async loadTransactions() {
try {
- // Use provided offset or current pagination offset, ensure it's an integer
- let currentOffset = 0
- if (offset !== null && offset !== undefined) {
- currentOffset = parseInt(offset)
- } else if (this.transactionPagination && this.transactionPagination.offset !== null && this.transactionPagination.offset !== undefined) {
- currentOffset = parseInt(this.transactionPagination.offset)
- }
-
- // Final safety check - ensure it's a valid number
- if (isNaN(currentOffset)) {
- currentOffset = 0
- }
-
- const limit = parseInt(this.transactionPagination.limit) || 20
-
- // Build query params with filters
- let queryParams = `limit=${limit}&offset=${currentOffset}`
-
- // Add date filter - custom range takes precedence over preset days
- if (this.transactionFilter.dateRangeType === 'custom' && this.transactionFilter.startDate && this.transactionFilter.endDate) {
- // Dates are already in YYYY-MM-DD format from q-date with mask
- queryParams += `&start_date=${this.transactionFilter.startDate}`
- queryParams += `&end_date=${this.transactionFilter.endDate}`
- } else {
- // Use preset days filter
- const days = typeof this.transactionFilter.dateRangeType === 'number' ? this.transactionFilter.dateRangeType : 15
- queryParams += `&days=${days}`
- }
-
- if (this.transactionFilter.user_id) {
- queryParams += `&filter_user_id=${this.transactionFilter.user_id}`
- }
- if (this.transactionFilter.account_type) {
- queryParams += `&filter_account_type=${this.transactionFilter.account_type}`
- }
-
const response = await LNbits.api.request(
'GET',
- `/castle/api/v1/entries/user?${queryParams}`,
+ '/castle/api/v1/entries/user',
this.g.user.wallets[0].inkey
)
-
- // Update transactions and pagination info
- this.transactions = response.data.entries
- this.transactionPagination.total = response.data.total
- this.transactionPagination.offset = parseInt(response.data.offset) || 0
- this.transactionPagination.has_next = response.data.has_next
- this.transactionPagination.has_prev = response.data.has_prev
+ this.transactions = response.data
} catch (error) {
LNbits.utils.notifyApiError(error)
- // Set empty array to clear loading state
- this.transactions = []
- this.transactionPagination.total = 0
- }
- },
- applyTransactionFilter() {
- // Reset to first page when applying filter
- this.transactionPagination.offset = 0
- this.loadTransactions(0)
- },
- clearTransactionFilter() {
- this.transactionFilter.user_id = null
- this.transactionFilter.account_type = null
- this.transactionPagination.offset = 0
- this.loadTransactions(0)
- },
- onDateRangeTypeChange(value) {
- // Handle date range type change (preset days or custom)
- if (value !== 'custom') {
- // Clear custom date range when switching to preset days
- this.transactionFilter.startDate = null
- this.transactionFilter.endDate = null
- // Load transactions with preset days
- this.transactionPagination.offset = 0
- this.loadTransactions(0)
- }
- // If switching to custom, don't load until user provides dates
- },
- applyCustomDateRange() {
- // Apply custom date range filter
- if (this.transactionFilter.startDate && this.transactionFilter.endDate) {
- this.transactionPagination.offset = 0
- this.loadTransactions(0)
- } else {
- this.$q.notify({
- type: 'warning',
- message: 'Please select both start and end dates',
- timeout: 3000
- })
- }
- },
- nextTransactionsPage() {
- if (this.transactionPagination.has_next) {
- const newOffset = this.transactionPagination.offset + this.transactionPagination.limit
- this.loadTransactions(newOffset)
- }
- },
- prevTransactionsPage() {
- if (this.transactionPagination.has_prev) {
- const newOffset = Math.max(0, this.transactionPagination.offset - this.transactionPagination.limit)
- this.loadTransactions(newOffset)
}
},
async loadAccounts() {
try {
const response = await LNbits.api.request(
'GET',
- '/castle/api/v1/accounts?filter_by_user=true&exclude_virtual=true',
+ '/castle/api/v1/accounts',
this.g.user.wallets[0].inkey
)
this.accounts = response.data
} catch (error) {
LNbits.utils.notifyApiError(error)
- // Set empty array to clear loading state
- this.accounts = []
}
},
async loadCurrencies() {
@@ -487,19 +353,6 @@ window.app = Vue.createApp({
console.error('Error loading users:', error)
}
},
- async loadUserInfo() {
- try {
- const response = await LNbits.api.request(
- 'GET',
- '/castle/api/v1/user/info',
- this.g.user.wallets[0].inkey
- )
- this.userInfo = response.data
- } catch (error) {
- console.error('Error loading user info:', error)
- this.userInfo = { is_equity_eligible: false }
- }
- },
async loadSettings() {
try {
// Try with admin key first to check settings
@@ -1138,8 +991,8 @@ window.app = Vue.createApp({
this.receivableDialog.currency = null
},
showSettleReceivableDialog(userBalance) {
- // Only show for users who owe castle (positive balance = receivable)
- if (userBalance.balance <= 0) return
+ // Only show for users who owe castle (negative balance)
+ if (userBalance.balance >= 0) return
// Clear any existing polling
if (this.settleReceivableDialog.pollIntervalId) {
@@ -1234,21 +1087,38 @@ window.app = Vue.createApp({
clearInterval(this.settleReceivableDialog.pollIntervalId)
this.settleReceivableDialog.pollIntervalId = null
- // Payment detected! The webhook (on_invoice_paid in tasks.py) will automatically
- // record this in Fava, so we don't need to call record-payment API here.
- // Just notify the user and refresh the UI.
- this.$q.notify({
- type: 'positive',
- message: 'Payment received! Receivable has been settled.',
- timeout: 3000
- })
+ // Record payment in accounting - this creates the journal entry
+ // that settles the receivable
+ try {
+ const recordResponse = await LNbits.api.request(
+ 'POST',
+ '/castle/api/v1/record-payment',
+ this.g.user.wallets[0].adminkey,
+ {
+ payment_hash: paymentHash
+ }
+ )
+ console.log('Settlement payment recorded:', recordResponse.data)
- // Close dialog and refresh
- this.settleReceivableDialog.show = false
- await this.loadBalance()
- await this.loadTransactions()
- await this.loadAllUserBalances()
+ this.$q.notify({
+ type: 'positive',
+ message: 'Payment received! Receivable has been settled.',
+ timeout: 3000
+ })
+ // Close dialog and refresh
+ this.settleReceivableDialog.show = false
+ await this.loadBalance()
+ await this.loadTransactions()
+ await this.loadAllUserBalances()
+ } catch (error) {
+ console.error('Error recording settlement payment:', error)
+ this.$q.notify({
+ type: 'negative',
+ message: 'Payment detected but failed to record: ' + (error.response?.data?.detail || error.message),
+ timeout: 5000
+ })
+ }
return true
}
return false
@@ -1330,8 +1200,8 @@ window.app = Vue.createApp({
}
},
showPayUserDialog(userBalance) {
- // Only show for users castle owes (negative balance = payable)
- if (userBalance.balance >= 0) return
+ // Only show for users castle owes (positive balance)
+ if (userBalance.balance <= 0) return
// Extract fiat balances (e.g., EUR)
const fiatBalances = userBalance.fiat_balances || {}
@@ -1534,30 +1404,52 @@ window.app = Vue.createApp({
return new Date(dateString).toLocaleDateString()
},
getTotalAmount(entry) {
- return entry.amount
+ if (!entry.lines || entry.lines.length === 0) return 0
+ return entry.lines.reduce((sum, line) => sum + line.debit + line.credit, 0) / 2
},
getEntryFiatAmount(entry) {
- if (entry.fiat_amount && entry.fiat_currency) {
- return this.formatFiat(entry.fiat_amount, entry.fiat_currency)
+ // Extract fiat amount from metadata if available
+ if (!entry.lines || entry.lines.length === 0) return null
+
+ for (const line of entry.lines) {
+ if (line.metadata && line.metadata.fiat_currency && line.metadata.fiat_amount) {
+ return this.formatFiat(line.metadata.fiat_amount, line.metadata.fiat_currency)
+ }
}
return null
},
isReceivable(entry) {
// Check if this is a receivable entry (user owes castle)
- if (entry.tags && entry.tags.includes('receivable-entry')) return true
- if (entry.account && entry.account.includes('Receivable')) return true
+ // Receivables have a debit to an "Accounts Receivable" account with the user's ID
+ if (!entry.lines || entry.lines.length === 0) return false
+
+ for (const line of entry.lines) {
+ // Look for a line with positive debit on an accounts receivable account
+ if (line.debit > 0) {
+ // Check if the account is associated with this user's receivables
+ const account = this.accounts.find(a => a.id === line.account_id)
+ if (account && account.name && account.name.includes('Assets:Receivable') && account.account_type === 'asset') {
+ return true
+ }
+ }
+ }
return false
},
isPayable(entry) {
// Check if this is a payable entry (castle owes user)
- if (entry.tags && entry.tags.includes('expense-entry')) return true
- if (entry.account && entry.account.includes('Payable')) return true
- return false
- },
- isEquity(entry) {
- // Check if this is an equity entry (user capital contribution/balance)
- if (entry.tags && entry.tags.includes('equity-contribution')) return true
- if (entry.account && entry.account.includes('Equity')) return true
+ // Payables have a credit to an "Accounts Payable" account with the user's ID
+ if (!entry.lines || entry.lines.length === 0) return false
+
+ for (const line of entry.lines) {
+ // Look for a line with positive credit on an accounts payable account
+ if (line.credit > 0) {
+ // Check if the account is associated with this user's payables
+ const account = this.accounts.find(a => a.id === line.account_id)
+ if (account && account.name && account.name.includes('Liabilities:Payable') && account.account_type === 'liability') {
+ return true
+ }
+ }
+ }
return false
}
},
@@ -1565,7 +1457,6 @@ window.app = Vue.createApp({
// Load settings first to determine if user is super user
await this.loadSettings()
await this.loadUserWallet()
- await this.loadUserInfo()
await this.loadExchangeRate()
await this.loadBalance()
await this.loadTransactions()
diff --git a/static/js/permissions.js b/static/js/permissions.js
deleted file mode 100644
index 0de3569..0000000
--- a/static/js/permissions.js
+++ /dev/null
@@ -1,1122 +0,0 @@
-window.app = Vue.createApp({
- mixins: [windowMixin],
- data() {
- return {
- permissions: [],
- accounts: [],
- users: [],
- filteredUsers: [],
- equityEligibleUsers: [],
- loading: false,
- granting: false,
- revoking: false,
- grantingEquity: false,
- revokingEquity: false,
- activeTab: 'by-user',
- showGrantDialog: false,
- showRevokeDialog: false,
- showGrantEquityDialog: false,
- showRevokeEquityDialog: false,
- showBulkGrantDialog: false,
- showBulkGrantErrors: false,
- permissionToRevoke: null,
- equityToRevoke: null,
- bulkGranting: false,
- bulkGrantResults: null,
- isSuperUser: false,
- grantForm: {
- user_id: '',
- account_id: '',
- permission_type: 'submit_expense',
- notes: '',
- expires_at: ''
- },
- grantEquityForm: {
- user_id: '',
- notes: ''
- },
- bulkGrantForm: {
- user_ids: [],
- account_id: '',
- permission_type: 'submit_expense',
- notes: '',
- expires_at: ''
- },
- permissionTypeOptions: [
- {
- value: 'read',
- label: 'Read',
- description: 'View account and balance'
- },
- {
- value: 'submit_expense',
- label: 'Submit Expense',
- description: 'Submit expenses to this account'
- },
- {
- value: 'manage',
- label: 'Manage',
- description: 'Full account management'
- }
- ],
- // RBAC-related data
- roles: [],
- selectedRole: null,
- roleToDelete: null,
- editingRole: false,
- showCreateRoleDialog: false,
- showViewRoleDialog: false,
- showDeleteRoleDialog: false,
- showAssignRoleDialog: false,
- showRevokeUserRoleDialog: false,
- savingRole: false,
- deletingRole: false,
- assigningRole: false,
- revokingUserRole: false,
- userRoleToRevoke: null,
- roleForm: {
- name: '',
- description: '',
- is_default: false
- },
- assignRoleForm: {
- user_id: '',
- role_id: '',
- expires_at: '',
- notes: ''
- },
- roleUsersForView: [],
- rolePermissionsForView: [],
- userRoles: new Map(), // Map of user_id -> array of roles
- showAddRolePermissionDialog: false,
- rolePermissionForm: {
- account_id: '',
- permission_type: '',
- notes: ''
- }
- }
- },
-
- computed: {
- accountOptions() {
- return this.accounts.map(acc => ({
- id: acc.id,
- name: acc.name,
- is_virtual: acc.is_virtual || false
- }))
- },
-
- userOptions() {
- const users = this.filteredUsers.length > 0 ? this.filteredUsers : this.users
- return users.map(user => ({
- id: user.id,
- username: user.username || user.id,
- label: user.username ? `${user.username} (${user.id.substring(0, 8)}...)` : user.id
- }))
- },
-
- isGrantFormValid() {
- return !!(
- this.grantForm.user_id &&
- this.grantForm.account_id &&
- this.grantForm.permission_type
- )
- },
-
- isBulkGrantFormValid() {
- return !!(
- this.bulkGrantForm.user_ids &&
- this.bulkGrantForm.user_ids.length > 0 &&
- this.bulkGrantForm.account_id &&
- this.bulkGrantForm.permission_type
- )
- },
-
- permissionsByUser() {
- const grouped = new Map()
- for (const perm of this.permissions) {
- if (!grouped.has(perm.user_id)) {
- grouped.set(perm.user_id, [])
- }
- grouped.get(perm.user_id).push(perm)
- }
- return grouped
- },
-
- // Get all unique user IDs from both direct permissions and role assignments
- allUserIds() {
- const userIds = new Set()
-
- // Add users with direct permissions
- for (const userId of this.permissionsByUser.keys()) {
- userIds.add(userId)
- }
-
- // Add users with role assignments
- for (const userId of this.userRoles.keys()) {
- userIds.add(userId)
- }
-
- return Array.from(userIds).sort()
- },
-
- permissionsByAccount() {
- const grouped = new Map()
- for (const perm of this.permissions) {
- if (!grouped.has(perm.account_id)) {
- grouped.set(perm.account_id, [])
- }
- grouped.get(perm.account_id).push(perm)
- }
- return grouped
- },
-
- roleOptions() {
- return this.roles.map(role => ({
- value: role.id,
- label: role.name,
- description: role.description,
- is_default: role.is_default
- }))
- },
-
- accountOptions() {
- return this.accounts.map(account => ({
- value: account.id,
- label: account.name,
- name: account.name,
- description: account.account_type,
- is_virtual: account.is_virtual
- }))
- }
- },
-
- methods: {
- async loadPermissions() {
- if (!this.isSuperUser) {
- this.$q.notify({
- type: 'warning',
- message: 'Admin access required to view permissions',
- timeout: 3000
- })
- return
- }
-
- this.loading = true
- try {
- const response = await LNbits.api.request(
- 'GET',
- '/castle/api/v1/admin/permissions',
- this.g.user.wallets[0].adminkey
- )
- this.permissions = response.data
- } catch (error) {
- console.error('Failed to load permissions:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to load permissions',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- } finally {
- this.loading = false
- }
- },
-
- async loadAccounts() {
- try {
- // Admin permissions UI needs to see virtual accounts to grant permissions on them
- const response = await LNbits.api.request(
- 'GET',
- '/castle/api/v1/accounts?exclude_virtual=false',
- this.g.user.wallets[0].inkey
- )
- this.accounts = response.data
- } catch (error) {
- console.error('Failed to load accounts:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to load accounts',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- }
- },
-
- async loadUsers() {
- if (!this.isSuperUser) {
- return
- }
-
- try {
- const response = await LNbits.api.request(
- 'GET',
- '/castle/api/v1/admin/castle-users',
- this.g.user.wallets[0].adminkey
- )
- this.users = response.data || []
- this.filteredUsers = []
- } catch (error) {
- console.error('Failed to load users:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to load users',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- }
- },
-
- filterUsers(val, update) {
- if (val === '') {
- update(() => {
- this.filteredUsers = []
- })
- return
- }
-
- update(() => {
- const needle = val.toLowerCase()
- this.filteredUsers = this.users.filter(user => {
- const username = user.username || ''
- const userId = user.id || ''
- return username.toLowerCase().includes(needle) || userId.toLowerCase().includes(needle)
- })
- })
- },
-
- async grantPermission() {
- if (!this.isGrantFormValid) {
- this.$q.notify({
- type: 'warning',
- message: 'Please fill in all required fields',
- timeout: 3000
- })
- return
- }
-
- this.granting = true
- try {
- // Extract account_id - handle both string and object cases
- const accountId = typeof this.grantForm.account_id === 'object'
- ? (this.grantForm.account_id.value || this.grantForm.account_id.id)
- : this.grantForm.account_id
-
- const payload = {
- user_id: this.grantForm.user_id,
- account_id: accountId,
- permission_type: this.grantForm.permission_type
- }
-
- if (this.grantForm.notes) {
- payload.notes = this.grantForm.notes
- }
-
- if (this.grantForm.expires_at) {
- payload.expires_at = new Date(this.grantForm.expires_at).toISOString()
- }
-
- await LNbits.api.request(
- 'POST',
- '/castle/api/v1/admin/permissions',
- this.g.user.wallets[0].adminkey,
- payload
- )
-
- this.$q.notify({
- type: 'positive',
- message: 'Permission granted successfully',
- timeout: 3000
- })
-
- this.showGrantDialog = false
- this.resetGrantForm()
- await this.loadPermissions()
- } catch (error) {
- console.error('Failed to grant permission:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to grant permission',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- } finally {
- this.granting = false
- }
- },
-
- confirmRevokePermission(permission) {
- this.permissionToRevoke = permission
- this.showRevokeDialog = true
- },
-
- async revokePermission() {
- if (!this.permissionToRevoke) return
-
- this.revoking = true
- try {
- await LNbits.api.request(
- 'DELETE',
- `/castle/api/v1/admin/permissions/${this.permissionToRevoke.id}`,
- this.g.user.wallets[0].adminkey
- )
-
- this.$q.notify({
- type: 'positive',
- message: 'Permission revoked successfully',
- timeout: 3000
- })
-
- this.showRevokeDialog = false
- this.permissionToRevoke = null
- await this.loadPermissions()
- } catch (error) {
- console.error('Failed to revoke permission:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to revoke permission',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- } finally {
- this.revoking = false
- }
- },
-
- resetGrantForm() {
- this.grantForm = {
- user_id: '',
- account_id: '',
- permission_type: 'submit_expense',
- notes: '',
- expires_at: ''
- }
- },
-
- async bulkGrantPermissions() {
- if (!this.isBulkGrantFormValid) {
- this.$q.notify({
- type: 'warning',
- message: 'Please fill in all required fields',
- timeout: 3000
- })
- return
- }
-
- this.bulkGranting = true
- this.bulkGrantResults = null
-
- try {
- // Extract account_id - handle both string and object cases
- const accountId = typeof this.bulkGrantForm.account_id === 'object'
- ? (this.bulkGrantForm.account_id.value || this.bulkGrantForm.account_id.id)
- : this.bulkGrantForm.account_id
-
- const payload = {
- user_ids: this.bulkGrantForm.user_ids,
- account_id: accountId,
- permission_type: this.bulkGrantForm.permission_type
- }
-
- if (this.bulkGrantForm.notes) {
- payload.notes = this.bulkGrantForm.notes
- }
-
- if (this.bulkGrantForm.expires_at) {
- payload.expires_at = new Date(this.bulkGrantForm.expires_at).toISOString()
- }
-
- const response = await LNbits.api.request(
- 'POST',
- '/castle/api/v1/admin/permissions/bulk-grant',
- this.g.user.wallets[0].adminkey,
- payload
- )
-
- this.bulkGrantResults = response.data
-
- // Show success notification
- const message = this.bulkGrantResults.failure_count > 0
- ? `Bulk grant completed: ${this.bulkGrantResults.success_count} succeeded, ${this.bulkGrantResults.failure_count} failed`
- : `Successfully granted permissions to ${this.bulkGrantResults.success_count} users`
-
- this.$q.notify({
- type: this.bulkGrantResults.failure_count > 0 ? 'warning' : 'positive',
- message: message,
- timeout: 5000
- })
-
- // Reload permissions to show new grants
- await this.loadPermissions()
-
- // Don't close dialog immediately if there were failures
- // (so user can review errors)
- if (this.bulkGrantResults.failure_count === 0) {
- setTimeout(() => {
- this.closeBulkGrantDialog()
- }, 2000)
- }
- } catch (error) {
- console.error('Failed to bulk grant permissions:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to bulk grant permissions',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- } finally {
- this.bulkGranting = false
- }
- },
-
- closeBulkGrantDialog() {
- this.showBulkGrantDialog = false
- this.resetBulkGrantForm()
- this.bulkGrantResults = null
- },
-
- resetBulkGrantForm() {
- this.bulkGrantForm = {
- user_ids: [],
- account_id: '',
- permission_type: 'submit_expense',
- notes: '',
- expires_at: ''
- }
- },
-
- getAccountName(accountId) {
- const account = this.accounts.find(a => a.id === accountId)
- return account ? account.name : accountId
- },
-
- getPermissionLabel(permissionType) {
- const option = this.permissionTypeOptions.find(opt => opt.value === permissionType)
- return option ? option.label : permissionType
- },
-
- getPermissionColor(permissionType) {
- switch (permissionType) {
- case 'read':
- return 'blue'
- case 'submit_expense':
- return 'green'
- case 'manage':
- return 'red'
- default:
- return 'grey'
- }
- },
-
- getPermissionIcon(permissionType) {
- switch (permissionType) {
- case 'read':
- return 'visibility'
- case 'submit_expense':
- return 'add_circle'
- case 'manage':
- return 'admin_panel_settings'
- default:
- return 'security'
- }
- },
-
- formatDate(dateString) {
- if (!dateString) return '-'
- const date = new Date(dateString)
- return date.toLocaleString()
- },
-
- async loadEquityEligibleUsers() {
- if (!this.isSuperUser) {
- return
- }
-
- try {
- const response = await LNbits.api.request(
- 'GET',
- '/castle/api/v1/admin/equity-eligibility',
- this.g.user.wallets[0].adminkey
- )
- this.equityEligibleUsers = response.data || []
- } catch (error) {
- console.error('Failed to load equity-eligible users:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to load equity-eligible users',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- }
- },
-
- async grantEquityEligibility() {
- if (!this.grantEquityForm.user_id) {
- this.$q.notify({
- type: 'warning',
- message: 'Please select a user',
- timeout: 3000
- })
- return
- }
-
- this.grantingEquity = true
- try {
- const payload = {
- user_id: this.grantEquityForm.user_id,
- is_equity_eligible: true
- }
-
- if (this.grantEquityForm.notes) {
- payload.notes = this.grantEquityForm.notes
- }
-
- await LNbits.api.request(
- 'POST',
- '/castle/api/v1/admin/equity-eligibility',
- this.g.user.wallets[0].adminkey,
- payload
- )
-
- this.$q.notify({
- type: 'positive',
- message: 'Equity eligibility granted successfully',
- timeout: 3000
- })
-
- this.showGrantEquityDialog = false
- this.resetGrantEquityForm()
- await this.loadEquityEligibleUsers()
- } catch (error) {
- console.error('Failed to grant equity eligibility:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to grant equity eligibility',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- } finally {
- this.grantingEquity = false
- }
- },
-
- confirmRevokeEquity(equity) {
- this.equityToRevoke = equity
- this.showRevokeEquityDialog = true
- },
-
- async revokeEquityEligibility() {
- if (!this.equityToRevoke) return
-
- this.revokingEquity = true
- try {
- await LNbits.api.request(
- 'DELETE',
- `/castle/api/v1/admin/equity-eligibility/${this.equityToRevoke.user_id}`,
- this.g.user.wallets[0].adminkey
- )
-
- this.$q.notify({
- type: 'positive',
- message: 'Equity eligibility revoked successfully',
- timeout: 3000
- })
-
- this.showRevokeEquityDialog = false
- this.equityToRevoke = null
- await this.loadEquityEligibleUsers()
- } catch (error) {
- console.error('Failed to revoke equity eligibility:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to revoke equity eligibility',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- } finally {
- this.revokingEquity = false
- }
- },
-
- resetGrantEquityForm() {
- this.grantEquityForm = {
- user_id: '',
- notes: ''
- }
- },
-
- // ===== RBAC ROLE MANAGEMENT METHODS =====
-
- async loadRoles() {
- if (!this.isSuperUser) {
- return
- }
-
- try {
- const response = await LNbits.api.request(
- 'GET',
- '/castle/api/v1/admin/roles',
- this.g.user.wallets[0].adminkey
- )
- this.roles = response.data || []
- } catch (error) {
- console.error('Failed to load roles:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to load roles',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- }
- },
-
- async viewRole(role) {
- this.selectedRole = role
- this.roleUsersForView = []
- this.rolePermissionsForView = []
-
- try {
- const response = await LNbits.api.request(
- 'GET',
- `/castle/api/v1/admin/roles/${role.id}`,
- this.g.user.wallets[0].adminkey
- )
-
- // Create fresh arrays to ensure Vue reactivity works properly
- this.rolePermissionsForView = [...(response.data.permissions || [])]
- this.roleUsersForView = [...(response.data.users || [])]
-
- // Wait for Vue to update the DOM before showing dialog
- await this.$nextTick()
- this.showViewRoleDialog = true
- } catch (error) {
- console.error('Failed to load role details:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to load role details',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- }
- },
-
- editRole(role) {
- this.editingRole = true
- this.selectedRole = role
- this.roleForm = {
- name: role.name,
- description: role.description || '',
- is_default: role.is_default || false
- }
- this.showCreateRoleDialog = true
- },
-
- async saveRole() {
- if (!this.roleForm.name) {
- this.$q.notify({
- type: 'warning',
- message: 'Please enter a role name',
- timeout: 3000
- })
- return
- }
-
- this.savingRole = true
- try {
- const payload = {
- name: this.roleForm.name,
- description: this.roleForm.description || null,
- is_default: this.roleForm.is_default || false
- }
-
- if (this.editingRole) {
- // Update existing role
- await LNbits.api.request(
- 'PUT',
- `/castle/api/v1/admin/roles/${this.selectedRole.id}`,
- this.g.user.wallets[0].adminkey,
- payload
- )
-
- this.$q.notify({
- type: 'positive',
- message: 'Role updated successfully',
- timeout: 3000
- })
- } else {
- // Create new role
- await LNbits.api.request(
- 'POST',
- '/castle/api/v1/admin/roles',
- this.g.user.wallets[0].adminkey,
- payload
- )
-
- this.$q.notify({
- type: 'positive',
- message: 'Role created successfully',
- timeout: 3000
- })
- }
-
- this.closeRoleDialog()
- await this.loadRoles()
- } catch (error) {
- console.error('Failed to save role:', error)
- this.$q.notify({
- type: 'negative',
- message: `Failed to ${this.editingRole ? 'update' : 'create'} role`,
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- } finally {
- this.savingRole = false
- }
- },
-
- confirmDeleteRole(role) {
- this.roleToDelete = role
- this.showDeleteRoleDialog = true
- },
-
- async deleteRole() {
- if (!this.roleToDelete) return
-
- this.deletingRole = true
- try {
- await LNbits.api.request(
- 'DELETE',
- `/castle/api/v1/admin/roles/${this.roleToDelete.id}`,
- this.g.user.wallets[0].adminkey
- )
-
- this.$q.notify({
- type: 'positive',
- message: 'Role deleted successfully',
- timeout: 3000
- })
-
- this.closeDeleteRoleDialog()
- await this.loadRoles()
- } catch (error) {
- console.error('Failed to delete role:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to delete role',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- } finally {
- this.deletingRole = false
- }
- },
-
- closeRoleDialog() {
- this.showCreateRoleDialog = false
- this.editingRole = false
- this.selectedRole = null
- this.resetRoleForm()
- },
-
- closeViewRoleDialog() {
- this.showViewRoleDialog = false
- this.selectedRole = null
- this.roleUsersForView = []
- this.rolePermissionsForView = []
- },
-
- closeDeleteRoleDialog() {
- this.showDeleteRoleDialog = false
- this.roleToDelete = null
- },
-
- closeAssignRoleDialog() {
- this.showAssignRoleDialog = false
- this.resetAssignRoleForm()
- },
-
- async assignRole() {
- if (!this.assignRoleForm.user_id || !this.assignRoleForm.role_id) {
- this.$q.notify({
- type: 'warning',
- message: 'Please select both a user and a role',
- timeout: 3000
- })
- return
- }
-
- this.assigningRole = true
- try {
- const payload = {
- user_id: this.assignRoleForm.user_id,
- role_id: this.assignRoleForm.role_id
- }
-
- if (this.assignRoleForm.notes) {
- payload.notes = this.assignRoleForm.notes
- }
-
- if (this.assignRoleForm.expires_at) {
- payload.expires_at = new Date(this.assignRoleForm.expires_at).toISOString()
- }
-
- await LNbits.api.request(
- 'POST',
- '/castle/api/v1/admin/user-roles',
- this.g.user.wallets[0].adminkey,
- payload
- )
-
- this.$q.notify({
- type: 'positive',
- message: 'Role assigned successfully',
- timeout: 3000
- })
-
- this.closeAssignRoleDialog()
- await this.loadRoles()
- } catch (error) {
- console.error('Failed to assign role:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to assign role',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- } finally {
- this.assigningRole = false
- }
- },
-
- resetRoleForm() {
- this.roleForm = {
- name: '',
- description: '',
- is_default: false
- }
- },
-
- resetAssignRoleForm() {
- this.assignRoleForm = {
- user_id: '',
- role_id: '',
- expires_at: '',
- notes: ''
- }
- },
-
- // Get roles for a specific user
- getUserRoles(userId) {
- const userRoleAssignments = this.userRoles.get(userId) || []
- // Map role assignments to role objects
- return userRoleAssignments
- .map(ur => this.roles.find(r => r.id === ur.role_id))
- .filter(r => r) // Filter out null/undefined
- },
-
- // Load all user role assignments
- async loadUserRoles() {
- if (!this.isSuperUser) return
- try {
- const response = await LNbits.api.request(
- 'GET',
- '/castle/api/v1/admin/users/roles',
- this.g.user.wallets[0].adminkey
- )
-
- // Group by user_id
- this.userRoles.clear()
- if (response.data && Array.isArray(response.data)) {
- response.data.forEach(userRole => {
- if (!this.userRoles.has(userRole.user_id)) {
- this.userRoles.set(userRole.user_id, [])
- }
- this.userRoles.get(userRole.user_id).push(userRole)
- })
- }
- } catch (error) {
- console.error('Failed to load user roles:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to load user role assignments',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- }
- },
-
- // Get user role assignments (returns UserRole objects, not Role objects)
- getUserRoleAssignments(userId) {
- return this.userRoles.get(userId) || []
- },
-
- // Get role name by ID
- getRoleName(roleId) {
- const role = this.roles.find(r => r.id === roleId)
- return role ? role.name : 'Unknown Role'
- },
-
- // View role by ID
- viewRoleById(roleId) {
- const role = this.roles.find(r => r.id === roleId)
- if (role) {
- this.viewRole(role)
- }
- },
-
- // Show assign role dialog with user pre-selected
- showAssignRoleForUser(userId) {
- this.assignRoleForm.user_id = userId
- this.showAssignRoleDialog = true
- },
-
- // Show confirmation dialog for revoking user role
- confirmRevokeUserRole(userRole) {
- this.userRoleToRevoke = userRole
- this.showRevokeUserRoleDialog = true
- },
-
- // Revoke user role
- async revokeUserRole() {
- if (!this.userRoleToRevoke) return
-
- this.revokingUserRole = true
- try {
- await LNbits.api.request(
- 'DELETE',
- `/castle/api/v1/admin/user-roles/${this.userRoleToRevoke.id}`,
- this.g.user.wallets[0].adminkey
- )
-
- this.$q.notify({
- type: 'positive',
- message: 'Role revoked successfully',
- timeout: 3000
- })
-
- // Reload data
- await this.loadUserRoles()
- await this.loadRoles()
-
- // Close dialog
- this.showRevokeUserRoleDialog = false
- this.userRoleToRevoke = null
- } catch (error) {
- console.error('Failed to revoke role:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to revoke role',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- } finally {
- this.revokingUserRole = false
- }
- },
-
- // Add permission to role
- async addRolePermission() {
- if (!this.selectedRole || !this.rolePermissionForm.account_id || !this.rolePermissionForm.permission_type) {
- return
- }
- try {
- // Extract account_id - handle both string and object cases
- const accountId = typeof this.rolePermissionForm.account_id === 'object'
- ? (this.rolePermissionForm.account_id.value || this.rolePermissionForm.account_id.id)
- : this.rolePermissionForm.account_id
-
- const payload = {
- role_id: this.selectedRole.id,
- account_id: accountId,
- permission_type: this.rolePermissionForm.permission_type,
- notes: this.rolePermissionForm.notes || null
- }
- await LNbits.api.request(
- 'POST',
- `/castle/api/v1/admin/roles/${this.selectedRole.id}/permissions`,
- this.g.user.wallets[0].adminkey,
- payload
- )
- this.closeAddRolePermissionDialog()
- // Reload role permissions
- await this.viewRole(this.selectedRole)
- this.$q.notify({
- type: 'positive',
- message: 'Permission added to role successfully',
- timeout: 3000
- })
- } catch (error) {
- console.error('Failed to add permission to role:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to add permission to role',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- }
- },
-
- // Delete role permission
- async deleteRolePermission(permissionId) {
- this.$q.dialog({
- title: 'Confirm',
- message: 'Are you sure you want to remove this permission from the role?',
- cancel: true,
- persistent: true
- }).onOk(async () => {
- try {
- await LNbits.api.request(
- 'DELETE',
- `/castle/api/v1/admin/roles/${this.selectedRole.id}/permissions/${permissionId}`,
- this.g.user.wallets[0].adminkey
- )
- // Reload role permissions
- await this.viewRole(this.selectedRole)
- this.$q.notify({
- type: 'positive',
- message: 'Permission removed from role',
- timeout: 3000
- })
- } catch (error) {
- console.error('Failed to delete role permission:', error)
- this.$q.notify({
- type: 'negative',
- message: 'Failed to remove permission',
- caption: error.message || 'Unknown error',
- timeout: 5000
- })
- }
- })
- },
-
- // Close add role permission dialog
- closeAddRolePermissionDialog() {
- this.showAddRolePermissionDialog = false
- this.rolePermissionForm = {
- account_id: '',
- permission_type: '',
- notes: ''
- }
- }
- },
-
- async created() {
- // Check if user is super user
- this.isSuperUser = this.g.user.super_user || false
-
- if (this.g.user.wallets && this.g.user.wallets.length > 0) {
- await this.loadAccounts()
- if (this.isSuperUser) {
- await Promise.all([
- this.loadPermissions(),
- this.loadUsers(),
- this.loadEquityEligibleUsers(),
- this.loadRoles(),
- this.loadUserRoles()
- ])
- }
- }
- }
-})
-
-window.app.mount('#vue')
diff --git a/tasks.py b/tasks.py
index 1a8327d..32333e1 100644
--- a/tasks.py
+++ b/tasks.py
@@ -95,59 +95,6 @@ async def scheduled_daily_reconciliation():
raise
-async def scheduled_account_sync():
- """
- Scheduled task that runs hourly to sync accounts from Beancount to Castle DB.
-
- This ensures Castle DB stays in sync with Beancount (source of truth) by
- automatically adding any new accounts created in Beancount to Castle's
- metadata database for permission tracking.
- """
- from .account_sync import sync_accounts_from_beancount
-
- logger.info(f"[CASTLE] Running scheduled account sync at {datetime.now()}")
-
- try:
- stats = await sync_accounts_from_beancount(force_full_sync=False)
-
- if stats["accounts_added"] > 0:
- logger.info(
- f"[CASTLE] Account sync: Added {stats['accounts_added']} new accounts"
- )
-
- if stats["errors"]:
- logger.warning(
- f"[CASTLE] Account sync: {len(stats['errors'])} errors encountered"
- )
- for error in stats["errors"][:5]: # Log first 5 errors
- logger.error(f" - {error}")
-
- return stats
-
- except Exception as e:
- logger.error(f"[CASTLE] Error in scheduled account sync: {e}")
- raise
-
-
-async def wait_for_account_sync():
- """
- Background task that periodically syncs accounts from Beancount to Castle DB.
-
- Runs hourly to ensure Castle DB stays in sync with Beancount.
- """
- logger.info("[CASTLE] Account sync background task started")
-
- while True:
- try:
- # Run sync
- await scheduled_account_sync()
- except Exception as e:
- logger.error(f"[CASTLE] Account sync error: {e}")
-
- # Wait 1 hour before next sync
- await asyncio.sleep(3600) # 3600 seconds = 1 hour
-
-
def start_daily_reconciliation_task():
"""
Initialize the daily reconciliation task.
@@ -182,11 +129,11 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None:
"""
- Handle a paid Castle invoice by automatically submitting to Fava.
+ Handle a paid Castle invoice by automatically creating a journal entry.
This function is called automatically when any invoice on the Castle wallet
is paid. It checks if the invoice is a Castle payment and records it in
- Beancount via Fava.
+ the accounting system.
"""
# Only process Castle-specific payments
if not payment.extra or payment.extra.get("tag") != "castle":
@@ -198,119 +145,85 @@ async def on_invoice_paid(payment: Payment) -> None:
return
# Check if payment already recorded (idempotency)
- # Query Fava for existing entry with this payment hash link
- from .fava_client import get_fava_client
- import httpx
+ from .crud import get_journal_entry_by_reference
+ existing = await get_journal_entry_by_reference(payment.payment_hash)
+ if existing:
+ logger.info(f"Payment {payment.payment_hash} already recorded, skipping")
+ return
- fava = get_fava_client()
+ logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]}")
try:
- # Check if payment already recorded by fetching recent entries
- # Note: We can't use BQL query with `links ~ 'pattern'` because links is a set type
- # and BQL doesn't support regex matching on sets. Instead, fetch entries and filter in Python.
- link_to_find = f"ln-{payment.payment_hash[:16]}"
-
- async with httpx.AsyncClient(timeout=5.0) as client:
- # Get recent entries from Fava's journal endpoint
- response = await client.get(
- f"{fava.base_url}/api/journal",
- params={"time": ""} # Get all entries
- )
-
- if response.status_code == 200:
- 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
+ # Import here to avoid circular dependencies
+ from .crud import create_journal_entry, get_account_by_name, get_or_create_user_account
+ from .models import AccountType, CreateEntryLine, CreateJournalEntry, JournalEntryFlag
# Convert amount from millisatoshis to satoshis
amount_sats = payment.amount // 1000
# Extract fiat metadata from invoice (if present)
- fiat_currency = None
- fiat_amount = None
+ from decimal import Decimal
+ line_metadata = {}
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))
+ fiat_amount = payment.extra.get("fiat_amount")
+ fiat_rate = payment.extra.get("fiat_rate")
+ btc_rate = payment.extra.get("btc_rate")
- if not fiat_currency or not fiat_amount:
- logger.error(f"Payment {payment.payment_hash} missing fiat currency/amount metadata")
- return
+ if fiat_currency and fiat_amount:
+ line_metadata = {
+ "fiat_currency": fiat_currency,
+ "fiat_amount": str(fiat_amount),
+ "fiat_rate": fiat_rate,
+ "btc_rate": btc_rate,
+ }
- # 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
+ # Get user's receivable account (what user owes)
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"
- )
+
+ # Get lightning account
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
return
- # 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
+ # Create journal entry to record payment
+ # DR Assets:Bitcoin:Lightning, CR Assets:Receivable (User)
+ # This reduces what the user owes
+ entry_meta = {
+ "source": "lightning_payment",
+ "created_via": "auto_invoice_listener",
+ "payment_hash": payment.payment_hash,
+ "payer_user_id": user_id,
+ }
+
+ entry_data = CreateJournalEntry(
+ description=f"Lightning payment from user {user_id[:8]}",
+ reference=payment.payment_hash,
+ flag=JournalEntryFlag.CLEARED,
+ meta=entry_meta,
+ lines=[
+ CreateEntryLine(
+ account_id=lightning_account.id,
+ debit=amount_sats,
+ credit=0,
+ description="Lightning payment received",
+ metadata=line_metadata,
+ ),
+ CreateEntryLine(
+ account_id=user_receivable.id,
+ debit=0,
+ credit=amount_sats,
+ description="Payment applied to balance",
+ metadata=line_metadata,
+ ),
+ ],
)
- # 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')}"
- )
+ entry = await create_journal_entry(entry_data, user_id)
+ logger.info(f"Successfully recorded journal entry {entry.id} for payment {payment.payment_hash}")
except Exception as e:
logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}")
diff --git a/templates/castle/index.html b/templates/castle/index.html
index 6648e6c..b5d6a01 100644
--- a/templates/castle/index.html
+++ b/templates/castle/index.html
@@ -16,13 +16,10 @@
🏰 Castle Accounting
Track expenses, receivables, and balances for the collective
-
+
Configure Your Wallet
-
- Manage Permissions (Admin)
- Castle Settings (Super User Only)
@@ -81,8 +78,8 @@
{% raw %}{{ formatDate(entry.entry_date) }}{% endraw %}
-
- User: {% raw %}{{ entry.username }}{% endraw %}
+
+ User: {% raw %}{{ getUserName(entry.meta.user_id) }}{% endraw %}
Ref: {% raw %}{{ entry.reference }}{% endraw %}
@@ -182,7 +179,7 @@
-
+
{% raw %}{{ formatSats(Math.abs(props.row.balance)) }} sats{% endraw %}
-
@@ -601,7 +405,7 @@
icon="add"
label="Create Assertion"
>
- Write a balance assertion to Beancount ledger for automatic validation
+ Create a new balance assertion for reconciliation
@@ -710,7 +514,7 @@
- No balance assertions yet. Create one to add checkpoints to your Beancount ledger and verify accounting accuracy.
+ No balance assertions yet. Create one to verify your accounting accuracy.
@@ -850,8 +654,9 @@
-
- No accounts available
+
+
+ Loading accounts...
@@ -915,7 +720,6 @@
>
-
-
-
-
-
-
-
-
-
Create Balance Assertion
- Balance assertions are written to your Beancount ledger and validated automatically by Beancount.
- This verifies that an account's actual balance matches your expected balance at a specific date.
- If the assertion fails, Beancount will alert you to investigate the discrepancy. Castle stores
- metadata (tolerance, notes) for your convenience.
+ Balance assertions help you verify accounting accuracy by checking if an account's actual balance matches your expected balance. If the assertion fails, you'll be alerted to investigate the discrepancy.