12 KiB
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:
- Fiat amounts (EUR, USD) - The actual transaction currency
- 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
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:
# 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.amountis always in satoshis internally- The sign (debit/credit) transfers to the fiat amount
sats-equivalentstores 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):
# 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):
# 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:
-- ✅ 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):
- Caching solves the bottleneck: 60-80% performance improvement from caching account/permission lookups
- Iteration is necessary anyway: Even with BQL, we'd need to iterate postings to access metadata
- Manual aggregation is fast: The actual summation is not the bottleneck
- 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)
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:
# 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):
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):
# 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:
{
"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:
get_user_balance()- Calculate individual user balanceget_all_user_balances()- Calculate all user balancesget_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
# ✅ 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-equivalentmetadata (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.
# 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:
; 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):
# 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:
- Current architecture is semantically correct
- Performance is acceptable with caching
- Migration would break existing tooling
- EUR-primary aligns with accounting reality
Related Documentation
docs/BQL-BALANCE-QUERIES.md- Why BQL can't query metadata and performance analysisdocs/BQL-PRICE-NOTATION-SOLUTION.md- Alternative using price notation (not implemented)beancount_format.py- Functions that create entries with sats-equivalent metadatafava_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