386 lines
12 KiB
Markdown
386 lines
12 KiB
Markdown
# 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> SATS {<eur> 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
|