Add Docs
This commit is contained in:
parent
1d2eb05c36
commit
862fe0bfad
4 changed files with 2729 additions and 0 deletions
953
docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html
Normal file
953
docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html
Normal file
|
|
@ -0,0 +1,953 @@
|
|||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="" xml:lang="">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="generator" content="pandoc" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
|
||||
<title>ACCOUNTING-ANALYSIS-NET-SETTLEMENT</title>
|
||||
<style>
|
||||
code{white-space: pre-wrap;}
|
||||
span.smallcaps{font-variant: small-caps;}
|
||||
div.columns{display: flex; gap: min(4vw, 1.5em);}
|
||||
div.column{flex: auto; overflow-x: auto;}
|
||||
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
|
||||
/* The extra [class] is a hack that increases specificity enough to
|
||||
override a similar rule in reveal.js */
|
||||
ul.task-list[class]{list-style: none;}
|
||||
ul.task-list li input[type="checkbox"] {
|
||||
font-size: inherit;
|
||||
width: 0.8em;
|
||||
margin: 0 0.8em 0.2em -1.6em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.display.math{display: block; text-align: center; margin: 0.5rem auto;}
|
||||
/* CSS for syntax highlighting */
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
pre > code.sourceCode { white-space: pre; position: relative; }
|
||||
pre > code.sourceCode > span { display: inline-block; line-height: 1.25; }
|
||||
pre > code.sourceCode > span:empty { height: 1.2em; }
|
||||
.sourceCode { overflow: visible; }
|
||||
code.sourceCode > span { color: inherit; text-decoration: inherit; }
|
||||
div.sourceCode { margin: 1em 0; }
|
||||
pre.sourceCode { margin: 0; }
|
||||
@media screen {
|
||||
div.sourceCode { overflow: auto; }
|
||||
}
|
||||
@media print {
|
||||
pre > code.sourceCode { white-space: pre-wrap; }
|
||||
pre > code.sourceCode > span { text-indent: -5em; padding-left: 5em; }
|
||||
}
|
||||
pre.numberSource code
|
||||
{ counter-reset: source-line 0; }
|
||||
pre.numberSource code > span
|
||||
{ position: relative; left: -4em; counter-increment: source-line; }
|
||||
pre.numberSource code > span > a:first-child::before
|
||||
{ content: counter(source-line);
|
||||
position: relative; left: -1em; text-align: right; vertical-align: baseline;
|
||||
border: none; display: inline-block;
|
||||
-webkit-touch-callout: none; -webkit-user-select: none;
|
||||
-khtml-user-select: none; -moz-user-select: none;
|
||||
-ms-user-select: none; user-select: none;
|
||||
padding: 0 4px; width: 4em;
|
||||
color: #aaaaaa;
|
||||
}
|
||||
pre.numberSource { margin-left: 3em; border-left: 1px solid #aaaaaa; padding-left: 4px; }
|
||||
div.sourceCode
|
||||
{ }
|
||||
@media screen {
|
||||
pre > code.sourceCode > span > a:first-child::before { text-decoration: underline; }
|
||||
}
|
||||
code span.al { color: #ff0000; font-weight: bold; } /* Alert */
|
||||
code span.an { color: #60a0b0; font-weight: bold; font-style: italic; } /* Annotation */
|
||||
code span.at { color: #7d9029; } /* Attribute */
|
||||
code span.bn { color: #40a070; } /* BaseN */
|
||||
code span.bu { color: #008000; } /* BuiltIn */
|
||||
code span.cf { color: #007020; font-weight: bold; } /* ControlFlow */
|
||||
code span.ch { color: #4070a0; } /* Char */
|
||||
code span.cn { color: #880000; } /* Constant */
|
||||
code span.co { color: #60a0b0; font-style: italic; } /* Comment */
|
||||
code span.cv { color: #60a0b0; font-weight: bold; font-style: italic; } /* CommentVar */
|
||||
code span.do { color: #ba2121; font-style: italic; } /* Documentation */
|
||||
code span.dt { color: #902000; } /* DataType */
|
||||
code span.dv { color: #40a070; } /* DecVal */
|
||||
code span.er { color: #ff0000; font-weight: bold; } /* Error */
|
||||
code span.ex { } /* Extension */
|
||||
code span.fl { color: #40a070; } /* Float */
|
||||
code span.fu { color: #06287e; } /* Function */
|
||||
code span.im { color: #008000; font-weight: bold; } /* Import */
|
||||
code span.in { color: #60a0b0; font-weight: bold; font-style: italic; } /* Information */
|
||||
code span.kw { color: #007020; font-weight: bold; } /* Keyword */
|
||||
code span.op { color: #666666; } /* Operator */
|
||||
code span.ot { color: #007020; } /* Other */
|
||||
code span.pp { color: #bc7a00; } /* Preprocessor */
|
||||
code span.sc { color: #4070a0; } /* SpecialChar */
|
||||
code span.ss { color: #bb6688; } /* SpecialString */
|
||||
code span.st { color: #4070a0; } /* String */
|
||||
code span.va { color: #19177c; } /* Variable */
|
||||
code span.vs { color: #4070a0; } /* VerbatimString */
|
||||
code span.wa { color: #60a0b0; font-weight: bold; font-style: italic; } /* Warning */
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://latex.now.sh/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<nav id="TOC" role="doc-toc">
|
||||
<ul>
|
||||
<li><a href="#accounting-analysis-net-settlement-entry-pattern"
|
||||
id="toc-accounting-analysis-net-settlement-entry-pattern">Accounting
|
||||
Analysis: Net Settlement Entry Pattern</a>
|
||||
<ul>
|
||||
<li><a href="#executive-summary" id="toc-executive-summary">Executive
|
||||
Summary</a></li>
|
||||
<li><a href="#background-the-technical-challenge"
|
||||
id="toc-background-the-technical-challenge">Background: The Technical
|
||||
Challenge</a></li>
|
||||
<li><a href="#current-implementation"
|
||||
id="toc-current-implementation">Current Implementation</a>
|
||||
<ul>
|
||||
<li><a href="#transaction-example"
|
||||
id="toc-transaction-example">Transaction Example</a></li>
|
||||
<li><a href="#code-implementation" id="toc-code-implementation">Code
|
||||
Implementation</a></li>
|
||||
</ul></li>
|
||||
<li><a href="#accounting-issues-identified"
|
||||
id="toc-accounting-issues-identified">Accounting Issues Identified</a>
|
||||
<ul>
|
||||
<li><a href="#issue-1-zero-amount-postings"
|
||||
id="toc-issue-1-zero-amount-postings">Issue 1: Zero-Amount
|
||||
Postings</a></li>
|
||||
<li><a href="#issue-2-redundant-satoshi-tracking"
|
||||
id="toc-issue-2-redundant-satoshi-tracking">Issue 2: Redundant Satoshi
|
||||
Tracking</a></li>
|
||||
<li><a href="#issue-3-no-exchange-gainloss-recognition"
|
||||
id="toc-issue-3-no-exchange-gainloss-recognition">Issue 3: No Exchange
|
||||
Gain/Loss Recognition</a></li>
|
||||
<li><a href="#issue-4-semantic-misuse-of-price-notation"
|
||||
id="toc-issue-4-semantic-misuse-of-price-notation">Issue 4: Semantic
|
||||
Misuse of Price Notation</a></li>
|
||||
<li><a href="#issue-5-misnamed-function-and-incorrect-usage"
|
||||
id="toc-issue-5-misnamed-function-and-incorrect-usage">Issue 5: Misnamed
|
||||
Function and Incorrect Usage</a></li>
|
||||
</ul></li>
|
||||
<li><a href="#traditional-accounting-approaches"
|
||||
id="toc-traditional-accounting-approaches">Traditional Accounting
|
||||
Approaches</a>
|
||||
<ul>
|
||||
<li><a
|
||||
href="#approach-1-record-bitcoin-at-fair-market-value-tax-compliant"
|
||||
id="toc-approach-1-record-bitcoin-at-fair-market-value-tax-compliant">Approach
|
||||
1: Record Bitcoin at Fair Market Value (Tax Compliant)</a></li>
|
||||
<li><a href="#approach-2-simplified-eur-only-ledger-no-sats-positions"
|
||||
id="toc-approach-2-simplified-eur-only-ledger-no-sats-positions">Approach
|
||||
2: Simplified EUR-Only Ledger (No SATS Positions)</a></li>
|
||||
<li><a
|
||||
href="#approach-3-true-net-settlement-when-both-obligations-exist"
|
||||
id="toc-approach-3-true-net-settlement-when-both-obligations-exist">Approach
|
||||
3: True Net Settlement (When Both Obligations Exist)</a></li>
|
||||
</ul></li>
|
||||
<li><a href="#recommendations"
|
||||
id="toc-recommendations">Recommendations</a>
|
||||
<ul>
|
||||
<li><a href="#priority-1-immediate-fixes-easy-wins"
|
||||
id="toc-priority-1-immediate-fixes-easy-wins">Priority 1: Immediate
|
||||
Fixes (Easy Wins)</a></li>
|
||||
<li><a href="#priority-2-medium-term-improvements-compliance"
|
||||
id="toc-priority-2-medium-term-improvements-compliance">Priority 2:
|
||||
Medium-Term Improvements (Compliance)</a></li>
|
||||
<li><a href="#priority-3-long-term-architectural-decisions"
|
||||
id="toc-priority-3-long-term-architectural-decisions">Priority 3:
|
||||
Long-Term Architectural Decisions</a></li>
|
||||
</ul></li>
|
||||
<li><a href="#code-files-requiring-changes"
|
||||
id="toc-code-files-requiring-changes">Code Files Requiring Changes</a>
|
||||
<ul>
|
||||
<li><a href="#high-priority-immediate-fixes"
|
||||
id="toc-high-priority-immediate-fixes">High Priority (Immediate
|
||||
Fixes)</a></li>
|
||||
<li><a href="#medium-priority-compliance"
|
||||
id="toc-medium-priority-compliance">Medium Priority
|
||||
(Compliance)</a></li>
|
||||
</ul></li>
|
||||
<li><a href="#testing-requirements"
|
||||
id="toc-testing-requirements">Testing Requirements</a>
|
||||
<ul>
|
||||
<li><a href="#test-case-1-simple-receivable-payment-no-payable"
|
||||
id="toc-test-case-1-simple-receivable-payment-no-payable">Test Case 1:
|
||||
Simple Receivable Payment (No Payable)</a></li>
|
||||
<li><a href="#test-case-2-true-net-settlement"
|
||||
id="toc-test-case-2-true-net-settlement">Test Case 2: True Net
|
||||
Settlement</a></li>
|
||||
<li><a href="#test-case-3-exchange-gainloss-future"
|
||||
id="toc-test-case-3-exchange-gainloss-future">Test Case 3: Exchange
|
||||
Gain/Loss (Future)</a></li>
|
||||
</ul></li>
|
||||
<li><a href="#conclusion" id="toc-conclusion">Conclusion</a>
|
||||
<ul>
|
||||
<li><a href="#summary-of-issues" id="toc-summary-of-issues">Summary of
|
||||
Issues</a></li>
|
||||
<li><a href="#professional-assessment"
|
||||
id="toc-professional-assessment">Professional Assessment</a></li>
|
||||
<li><a href="#next-steps" id="toc-next-steps">Next Steps</a></li>
|
||||
</ul></li>
|
||||
<li><a href="#references" id="toc-references">References</a></li>
|
||||
</ul></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<h1 id="accounting-analysis-net-settlement-entry-pattern">Accounting
|
||||
Analysis: Net Settlement Entry Pattern</h1>
|
||||
<p><strong>Date</strong>: 2025-01-12 <strong>Prepared By</strong>:
|
||||
Senior Accounting Review <strong>Subject</strong>: Castle Extension -
|
||||
Lightning Payment Settlement Entries <strong>Status</strong>: Technical
|
||||
Review</p>
|
||||
<hr />
|
||||
<h2 id="executive-summary">Executive Summary</h2>
|
||||
<p>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.</p>
|
||||
<p><strong>Key Findings</strong>: - ✅ 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</p>
|
||||
<hr />
|
||||
<h2 id="background-the-technical-challenge">Background: The Technical
|
||||
Challenge</h2>
|
||||
<p>Castle operates as a Lightning Network-integrated accounting system
|
||||
for collectives (co-living spaces, makerspaces). It faces a unique
|
||||
accounting challenge:</p>
|
||||
<p><strong>Scenario</strong>: User creates a receivable in EUR (e.g.,
|
||||
€200 for room rent), then pays via Lightning Network in satoshis
|
||||
(225,033 sats).</p>
|
||||
<p><strong>Challenge</strong>: 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</p>
|
||||
<hr />
|
||||
<h2 id="current-implementation">Current Implementation</h2>
|
||||
<h3 id="transaction-example">Transaction Example</h3>
|
||||
<pre class="beancount"><code>; 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></pre>
|
||||
<h3 id="code-implementation">Code Implementation</h3>
|
||||
<p><strong>Location</strong>:
|
||||
<code>beancount_format.py:739-760</code></p>
|
||||
<div class="sourceCode" id="cb2"><pre
|
||||
class="sourceCode python"><code class="sourceCode python"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="co"># Build postings for net settlement</span></span>
|
||||
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a>postings <span class="op">=</span> [</span>
|
||||
<span id="cb2-3"><a href="#cb2-3" aria-hidden="true" tabindex="-1"></a> {</span>
|
||||
<span id="cb2-4"><a href="#cb2-4" aria-hidden="true" tabindex="-1"></a> <span class="st">"account"</span>: payment_account,</span>
|
||||
<span id="cb2-5"><a href="#cb2-5" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</span><span class="sc">{</span><span class="bu">abs</span>(amount_sats)<span class="sc">}</span><span class="ss"> SATS @@ </span><span class="sc">{</span><span class="bu">abs</span>(net_fiat_amount)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">"</span>,</span>
|
||||
<span id="cb2-6"><a href="#cb2-6" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {<span class="st">"payment-hash"</span>: payment_hash} <span class="cf">if</span> payment_hash <span class="cf">else</span> {}</span>
|
||||
<span id="cb2-7"><a href="#cb2-7" aria-hidden="true" tabindex="-1"></a> },</span>
|
||||
<span id="cb2-8"><a href="#cb2-8" aria-hidden="true" tabindex="-1"></a> {</span>
|
||||
<span id="cb2-9"><a href="#cb2-9" aria-hidden="true" tabindex="-1"></a> <span class="st">"account"</span>: receivable_account,</span>
|
||||
<span id="cb2-10"><a href="#cb2-10" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"-</span><span class="sc">{</span><span class="bu">abs</span>(total_receivable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">"</span>,</span>
|
||||
<span id="cb2-11"><a href="#cb2-11" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {<span class="st">"sats-equivalent"</span>: <span class="bu">str</span>(<span class="bu">abs</span>(amount_sats))}</span>
|
||||
<span id="cb2-12"><a href="#cb2-12" aria-hidden="true" tabindex="-1"></a> },</span>
|
||||
<span id="cb2-13"><a href="#cb2-13" aria-hidden="true" tabindex="-1"></a> {</span>
|
||||
<span id="cb2-14"><a href="#cb2-14" aria-hidden="true" tabindex="-1"></a> <span class="st">"account"</span>: payable_account,</span>
|
||||
<span id="cb2-15"><a href="#cb2-15" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</span><span class="sc">{</span><span class="bu">abs</span>(total_payable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">"</span>,</span>
|
||||
<span id="cb2-16"><a href="#cb2-16" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {}</span>
|
||||
<span id="cb2-17"><a href="#cb2-17" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||
<span id="cb2-18"><a href="#cb2-18" aria-hidden="true" tabindex="-1"></a>]</span></code></pre></div>
|
||||
<p><strong>Three-Posting Structure</strong>: 1. <strong>Lightning
|
||||
Account</strong>: Records SATS received with <code>@@</code> total price
|
||||
notation 2. <strong>Receivable Account</strong>: Clears EUR receivable
|
||||
with sats-equivalent metadata 3. <strong>Payable Account</strong>:
|
||||
Clears any outstanding EUR payables (often 0.00)</p>
|
||||
<hr />
|
||||
<h2 id="accounting-issues-identified">Accounting Issues Identified</h2>
|
||||
<h3 id="issue-1-zero-amount-postings">Issue 1: Zero-Amount Postings</h3>
|
||||
<p><strong>Problem</strong>: The third posting often records
|
||||
<code>0.00 EUR</code> when no payable exists.</p>
|
||||
<pre class="beancount"><code>Liabilities:Payable:User-375ec158 0.00 EUR</code></pre>
|
||||
<p><strong>Why This Is Wrong</strong>: - 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)</p>
|
||||
<p><strong>Accounting Principle Violated</strong>: > “Transactions
|
||||
should only include postings that represent actual economic events or
|
||||
changes in account balances.”</p>
|
||||
<p><strong>Impact</strong>: Low severity, but unprofessional
|
||||
presentation</p>
|
||||
<p><strong>Recommendation</strong>:</p>
|
||||
<div class="sourceCode" id="cb4"><pre
|
||||
class="sourceCode python"><code class="sourceCode python"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="co"># Make payable posting conditional</span></span>
|
||||
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a>postings <span class="op">=</span> [</span>
|
||||
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a> {<span class="st">"account"</span>: payment_account, <span class="st">"amount"</span>: ...},</span>
|
||||
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a> {<span class="st">"account"</span>: receivable_account, <span class="st">"amount"</span>: ...}</span>
|
||||
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a>]</span>
|
||||
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a><span class="co"># Only add payable posting if there's actually a payable</span></span>
|
||||
<span id="cb4-8"><a href="#cb4-8" aria-hidden="true" tabindex="-1"></a><span class="cf">if</span> total_payable_fiat <span class="op">></span> <span class="dv">0</span>:</span>
|
||||
<span id="cb4-9"><a href="#cb4-9" aria-hidden="true" tabindex="-1"></a> postings.append({</span>
|
||||
<span id="cb4-10"><a href="#cb4-10" aria-hidden="true" tabindex="-1"></a> <span class="st">"account"</span>: payable_account,</span>
|
||||
<span id="cb4-11"><a href="#cb4-11" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</span><span class="sc">{</span><span class="bu">abs</span>(total_payable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">"</span>,</span>
|
||||
<span id="cb4-12"><a href="#cb4-12" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {}</span>
|
||||
<span id="cb4-13"><a href="#cb4-13" aria-hidden="true" tabindex="-1"></a> })</span></code></pre></div>
|
||||
<hr />
|
||||
<h3 id="issue-2-redundant-satoshi-tracking">Issue 2: Redundant Satoshi
|
||||
Tracking</h3>
|
||||
<p><strong>Problem</strong>: Satoshis are tracked in TWO places in the
|
||||
same transaction:</p>
|
||||
<ol type="1">
|
||||
<li><p><strong>Position Amount</strong> (via <code>@@</code>
|
||||
notation):</p>
|
||||
<pre class="beancount"><code>Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR</code></pre></li>
|
||||
<li><p><strong>Metadata</strong> (sats-equivalent):</p>
|
||||
<pre class="beancount"><code>Assets:Receivable:User-375ec158 -200.00 EUR
|
||||
sats-equivalent: "225033"</code></pre></li>
|
||||
</ol>
|
||||
<p><strong>Why This Is Problematic</strong>: - The <code>@@</code>
|
||||
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</p>
|
||||
<p><strong>Technical Detail</strong>:</p>
|
||||
<p>The <code>@@</code> notation means “total price” and Beancount
|
||||
converts it to per-unit price:</p>
|
||||
<pre class="beancount"><code>; 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...)</code></pre>
|
||||
<p>Beancount can query this:</p>
|
||||
<div class="sourceCode" id="cb8"><pre
|
||||
class="sourceCode sql"><code class="sourceCode sql"><span id="cb8-1"><a href="#cb8-1" aria-hidden="true" tabindex="-1"></a><span class="kw">SELECT</span> <span class="kw">account</span>, <span class="fu">sum</span>(<span class="fu">convert</span>(position, SATS))</span>
|
||||
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a><span class="kw">WHERE</span> <span class="kw">account</span> <span class="op">=</span> <span class="st">'Assets:Bitcoin:Lightning'</span></span></code></pre></div>
|
||||
<p><strong>Recommendation</strong>:</p>
|
||||
<p>Choose ONE approach consistently:</p>
|
||||
<p><strong>Option A - Use @ notation</strong> (Beancount standard):</p>
|
||||
<pre class="beancount"><code>Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR
|
||||
payment-hash: "8d080ec4..."
|
||||
Assets:Receivable:User-375ec158 -200.00 EUR
|
||||
; No sats-equivalent needed here</code></pre>
|
||||
<p><strong>Option B - Use EUR positions with metadata</strong> (Castle’s
|
||||
current approach):</p>
|
||||
<pre class="beancount"><code>Assets:Bitcoin:Lightning 200.00 EUR
|
||||
sats-received: "225033"
|
||||
payment-hash: "8d080ec4..."
|
||||
Assets:Receivable:User-375ec158 -200.00 EUR
|
||||
sats-cleared: "225033"</code></pre>
|
||||
<p><strong>Don’t</strong>: Mix both in the same transaction (current
|
||||
implementation)</p>
|
||||
<hr />
|
||||
<h3 id="issue-3-no-exchange-gainloss-recognition">Issue 3: No Exchange
|
||||
Gain/Loss Recognition</h3>
|
||||
<p><strong>Problem</strong>: When receivables are denominated in one
|
||||
currency (EUR) and paid in another (SATS), exchange rate fluctuations
|
||||
create gains or losses that should be recognized.</p>
|
||||
<p><strong>Example Scenario</strong>:</p>
|
||||
<pre><code>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</code></pre>
|
||||
<p><strong>Current Implementation</strong>: Forces balance by
|
||||
calculating the <code>@</code> rate to make it exactly 200 EUR:</p>
|
||||
<pre class="beancount"><code>Assets:Bitcoin:Lightning 225033 SATS @ 0.000888... EUR ; = exactly 200.00 EUR</code></pre>
|
||||
<p>This <strong>hides the exchange variance</strong> by treating the
|
||||
payment as if it was worth exactly the receivable amount.</p>
|
||||
<p><strong>GAAP/IFRS Requirement</strong>:</p>
|
||||
<p>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.</p>
|
||||
<p><strong>Proper Accounting Treatment</strong>:</p>
|
||||
<pre class="beancount"><code>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</code></pre>
|
||||
<p><strong>Impact</strong>: Moderate severity - affects financial
|
||||
statement accuracy</p>
|
||||
<p><strong>Why This Matters</strong>: - 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</p>
|
||||
<hr />
|
||||
<h3 id="issue-4-semantic-misuse-of-price-notation">Issue 4: Semantic
|
||||
Misuse of Price Notation</h3>
|
||||
<p><strong>Problem</strong>: The <code>@</code> notation in Beancount
|
||||
represents <strong>acquisition cost</strong>, not <strong>settlement
|
||||
value</strong>.</p>
|
||||
<p><strong>Current Usage</strong>:</p>
|
||||
<pre class="beancount"><code>Assets:Bitcoin:Lightning 225033 SATS @ 0.000888... EUR</code></pre>
|
||||
<p><strong>What this notation means in accounting</strong>: “We
|
||||
<strong>purchased</strong> 225,033 satoshis at a cost of 0.000888 EUR
|
||||
per satoshi”</p>
|
||||
<p><strong>What actually happened</strong>: “We
|
||||
<strong>received</strong> 225,033 satoshis as payment for a debt”</p>
|
||||
<p><strong>Economic Difference</strong>: - <strong>Purchase</strong>:
|
||||
You exchange cash for an asset (buying Bitcoin) - <strong>Payment
|
||||
Receipt</strong>: You receive an asset in settlement of a receivable</p>
|
||||
<p><strong>Accounting Substance vs. Form</strong>: -
|
||||
<strong>Form</strong>: The transaction looks like a Bitcoin purchase -
|
||||
<strong>Substance</strong>: The transaction is actually a receivable
|
||||
collection</p>
|
||||
<p><strong>GAAP Principle (ASC 105-10-05)</strong>: > “Accounting
|
||||
should reflect the economic substance of transactions, not merely their
|
||||
legal form.”</p>
|
||||
<p><strong>Why This Creates Issues</strong>:</p>
|
||||
<ol type="1">
|
||||
<li><strong>Cost Basis Tracking</strong>: For tax purposes, the “cost”
|
||||
of Bitcoin received as payment should be its fair market value at
|
||||
receipt, not the receivable amount</li>
|
||||
<li><strong>Price Database Pollution</strong>: Beancount’s price
|
||||
database now contains “prices” that aren’t real market prices</li>
|
||||
<li><strong>Auditor Confusion</strong>: An auditor reviewing this would
|
||||
question why purchase prices don’t match market rates</li>
|
||||
</ol>
|
||||
<p><strong>Proper Accounting Approach</strong>:</p>
|
||||
<pre class="beancount"><code>; 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</code></pre>
|
||||
<hr />
|
||||
<h3 id="issue-5-misnamed-function-and-incorrect-usage">Issue 5: Misnamed
|
||||
Function and Incorrect Usage</h3>
|
||||
<p><strong>Problem</strong>: Function is called
|
||||
<code>format_net_settlement_entry</code>, but it’s used for simple
|
||||
payments that aren’t true net settlements.</p>
|
||||
<p><strong>Example from User’s Transaction</strong>: - Receivable:
|
||||
200.00 EUR - Payable: 0.00 EUR - Net: 200.00 EUR (this is just a
|
||||
<strong>payment</strong>, not a <strong>settlement</strong>)</p>
|
||||
<p><strong>Accounting Terminology</strong>:</p>
|
||||
<ul>
|
||||
<li><strong>Payment</strong>: Settling a single obligation (receivable
|
||||
OR payable)</li>
|
||||
<li><strong>Net Settlement</strong>: Offsetting multiple obligations
|
||||
(receivable AND payable)</li>
|
||||
</ul>
|
||||
<p><strong>When Net Settlement is Appropriate</strong>:</p>
|
||||
<pre><code>User owes Castle: 555.00 EUR (receivable)
|
||||
Castle owes User: 38.00 EUR (payable)
|
||||
Net amount due: 517.00 EUR (true settlement)</code></pre>
|
||||
<p>Proper three-posting entry:</p>
|
||||
<pre class="beancount"><code>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 ✓</code></pre>
|
||||
<p><strong>When Two Postings Suffice</strong>:</p>
|
||||
<pre><code>User owes Castle: 200.00 EUR (receivable)
|
||||
Castle owes User: 0.00 EUR (no payable)
|
||||
Amount due: 200.00 EUR (simple payment)</code></pre>
|
||||
<p>Simpler two-posting entry:</p>
|
||||
<pre class="beancount"><code>Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR
|
||||
Assets:Receivable:User -200.00 EUR</code></pre>
|
||||
<p><strong>Best Practice</strong>: Use the simplest journal entry
|
||||
structure that accurately represents the transaction.</p>
|
||||
<p><strong>Recommendation</strong>: 1. Rename function to
|
||||
<code>format_payment_entry</code> or
|
||||
<code>format_receivable_payment_entry</code> 2. Create separate
|
||||
<code>format_net_settlement_entry</code> for true netting scenarios 3.
|
||||
Use conditional logic to choose 2-posting vs 3-posting based on whether
|
||||
both receivables AND payables exist</p>
|
||||
<hr />
|
||||
<h2 id="traditional-accounting-approaches">Traditional Accounting
|
||||
Approaches</h2>
|
||||
<h3
|
||||
id="approach-1-record-bitcoin-at-fair-market-value-tax-compliant">Approach
|
||||
1: Record Bitcoin at Fair Market Value (Tax Compliant)</h3>
|
||||
<pre class="beancount"><code>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</code></pre>
|
||||
<p><strong>Pros</strong>: - ✅ Tax compliant (establishes cost basis) -
|
||||
✅ Recognizes exchange gain/loss - ✅ Uses actual market rates - ✅
|
||||
Audit trail for cryptocurrency receipts</p>
|
||||
<p><strong>Cons</strong>: - ❌ Requires real-time price feeds - ❌
|
||||
Creates taxable events</p>
|
||||
<hr />
|
||||
<h3
|
||||
id="approach-2-simplified-eur-only-ledger-no-sats-positions">Approach 2:
|
||||
Simplified EUR-Only Ledger (No SATS Positions)</h3>
|
||||
<pre class="beancount"><code>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</code></pre>
|
||||
<p><strong>Pros</strong>: - ✅ Simple and clean - ✅ EUR positions match
|
||||
accounting reality - ✅ SATS tracked in metadata for reference - ✅ No
|
||||
artificial price notation</p>
|
||||
<p><strong>Cons</strong>: - ❌ SATS not queryable via Beancount
|
||||
positions - ❌ Requires metadata parsing for SATS balances</p>
|
||||
<hr />
|
||||
<h3
|
||||
id="approach-3-true-net-settlement-when-both-obligations-exist">Approach
|
||||
3: True Net Settlement (When Both Obligations Exist)</h3>
|
||||
<pre class="beancount"><code>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</code></pre>
|
||||
<p><strong>When to Use</strong>: Only when <strong>both</strong>
|
||||
receivables and payables exist and you’re truly netting them.</p>
|
||||
<hr />
|
||||
<h2 id="recommendations">Recommendations</h2>
|
||||
<h3 id="priority-1-immediate-fixes-easy-wins">Priority 1: Immediate
|
||||
Fixes (Easy Wins)</h3>
|
||||
<h4 id="remove-zero-amount-postings">1.1 Remove Zero-Amount
|
||||
Postings</h4>
|
||||
<p><strong>File</strong>: <code>beancount_format.py:739-760</code></p>
|
||||
<p><strong>Current Code</strong>:</p>
|
||||
<div class="sourceCode" id="cb23"><pre
|
||||
class="sourceCode python"><code class="sourceCode python"><span id="cb23-1"><a href="#cb23-1" aria-hidden="true" tabindex="-1"></a>postings <span class="op">=</span> [</span>
|
||||
<span id="cb23-2"><a href="#cb23-2" aria-hidden="true" tabindex="-1"></a> {...}, <span class="co"># Lightning</span></span>
|
||||
<span id="cb23-3"><a href="#cb23-3" aria-hidden="true" tabindex="-1"></a> {...}, <span class="co"># Receivable</span></span>
|
||||
<span id="cb23-4"><a href="#cb23-4" aria-hidden="true" tabindex="-1"></a> { <span class="co"># Payable (always included, even if 0.00)</span></span>
|
||||
<span id="cb23-5"><a href="#cb23-5" aria-hidden="true" tabindex="-1"></a> <span class="st">"account"</span>: payable_account,</span>
|
||||
<span id="cb23-6"><a href="#cb23-6" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</span><span class="sc">{</span><span class="bu">abs</span>(total_payable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">"</span>,</span>
|
||||
<span id="cb23-7"><a href="#cb23-7" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {}</span>
|
||||
<span id="cb23-8"><a href="#cb23-8" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||
<span id="cb23-9"><a href="#cb23-9" aria-hidden="true" tabindex="-1"></a>]</span></code></pre></div>
|
||||
<p><strong>Fixed Code</strong>:</p>
|
||||
<div class="sourceCode" id="cb24"><pre
|
||||
class="sourceCode python"><code class="sourceCode python"><span id="cb24-1"><a href="#cb24-1" aria-hidden="true" tabindex="-1"></a>postings <span class="op">=</span> [</span>
|
||||
<span id="cb24-2"><a href="#cb24-2" aria-hidden="true" tabindex="-1"></a> {</span>
|
||||
<span id="cb24-3"><a href="#cb24-3" aria-hidden="true" tabindex="-1"></a> <span class="st">"account"</span>: payment_account,</span>
|
||||
<span id="cb24-4"><a href="#cb24-4" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</span><span class="sc">{</span><span class="bu">abs</span>(amount_sats)<span class="sc">}</span><span class="ss"> SATS @@ </span><span class="sc">{</span><span class="bu">abs</span>(net_fiat_amount)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">"</span>,</span>
|
||||
<span id="cb24-5"><a href="#cb24-5" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {<span class="st">"payment-hash"</span>: payment_hash} <span class="cf">if</span> payment_hash <span class="cf">else</span> {}</span>
|
||||
<span id="cb24-6"><a href="#cb24-6" aria-hidden="true" tabindex="-1"></a> },</span>
|
||||
<span id="cb24-7"><a href="#cb24-7" aria-hidden="true" tabindex="-1"></a> {</span>
|
||||
<span id="cb24-8"><a href="#cb24-8" aria-hidden="true" tabindex="-1"></a> <span class="st">"account"</span>: receivable_account,</span>
|
||||
<span id="cb24-9"><a href="#cb24-9" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"-</span><span class="sc">{</span><span class="bu">abs</span>(total_receivable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">"</span>,</span>
|
||||
<span id="cb24-10"><a href="#cb24-10" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {<span class="st">"sats-equivalent"</span>: <span class="bu">str</span>(<span class="bu">abs</span>(amount_sats))}</span>
|
||||
<span id="cb24-11"><a href="#cb24-11" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||
<span id="cb24-12"><a href="#cb24-12" aria-hidden="true" tabindex="-1"></a>]</span>
|
||||
<span id="cb24-13"><a href="#cb24-13" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb24-14"><a href="#cb24-14" aria-hidden="true" tabindex="-1"></a><span class="co"># Only add payable posting if there's actually a payable to clear</span></span>
|
||||
<span id="cb24-15"><a href="#cb24-15" aria-hidden="true" tabindex="-1"></a><span class="cf">if</span> total_payable_fiat <span class="op">></span> <span class="dv">0</span>:</span>
|
||||
<span id="cb24-16"><a href="#cb24-16" aria-hidden="true" tabindex="-1"></a> postings.append({</span>
|
||||
<span id="cb24-17"><a href="#cb24-17" aria-hidden="true" tabindex="-1"></a> <span class="st">"account"</span>: payable_account,</span>
|
||||
<span id="cb24-18"><a href="#cb24-18" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</span><span class="sc">{</span><span class="bu">abs</span>(total_payable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">"</span>,</span>
|
||||
<span id="cb24-19"><a href="#cb24-19" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {}</span>
|
||||
<span id="cb24-20"><a href="#cb24-20" aria-hidden="true" tabindex="-1"></a> })</span></code></pre></div>
|
||||
<p><strong>Impact</strong>: Cleaner journal, professional presentation,
|
||||
easier auditing</p>
|
||||
<hr />
|
||||
<h4 id="choose-one-sats-tracking-method">1.2 Choose One SATS Tracking
|
||||
Method</h4>
|
||||
<p><strong>Decision Required</strong>: Select either position-based OR
|
||||
metadata-based satoshi tracking.</p>
|
||||
<p><strong>Option A - Keep Metadata Approach</strong> (recommended for
|
||||
Castle):</p>
|
||||
<div class="sourceCode" id="cb25"><pre
|
||||
class="sourceCode python"><code class="sourceCode python"><span id="cb25-1"><a href="#cb25-1" aria-hidden="true" tabindex="-1"></a><span class="co"># In format_net_settlement_entry()</span></span>
|
||||
<span id="cb25-2"><a href="#cb25-2" aria-hidden="true" tabindex="-1"></a>postings <span class="op">=</span> [</span>
|
||||
<span id="cb25-3"><a href="#cb25-3" aria-hidden="true" tabindex="-1"></a> {</span>
|
||||
<span id="cb25-4"><a href="#cb25-4" aria-hidden="true" tabindex="-1"></a> <span class="st">"account"</span>: payment_account,</span>
|
||||
<span id="cb25-5"><a href="#cb25-5" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</span><span class="sc">{</span><span class="bu">abs</span>(net_fiat_amount)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">"</span>, <span class="co"># EUR only</span></span>
|
||||
<span id="cb25-6"><a href="#cb25-6" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {</span>
|
||||
<span id="cb25-7"><a href="#cb25-7" aria-hidden="true" tabindex="-1"></a> <span class="st">"sats-received"</span>: <span class="bu">str</span>(<span class="bu">abs</span>(amount_sats)),</span>
|
||||
<span id="cb25-8"><a href="#cb25-8" aria-hidden="true" tabindex="-1"></a> <span class="st">"payment-hash"</span>: payment_hash</span>
|
||||
<span id="cb25-9"><a href="#cb25-9" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||
<span id="cb25-10"><a href="#cb25-10" aria-hidden="true" tabindex="-1"></a> },</span>
|
||||
<span id="cb25-11"><a href="#cb25-11" aria-hidden="true" tabindex="-1"></a> {</span>
|
||||
<span id="cb25-12"><a href="#cb25-12" aria-hidden="true" tabindex="-1"></a> <span class="st">"account"</span>: receivable_account,</span>
|
||||
<span id="cb25-13"><a href="#cb25-13" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"-</span><span class="sc">{</span><span class="bu">abs</span>(total_receivable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">"</span>,</span>
|
||||
<span id="cb25-14"><a href="#cb25-14" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {<span class="st">"sats-cleared"</span>: <span class="bu">str</span>(<span class="bu">abs</span>(amount_sats))}</span>
|
||||
<span id="cb25-15"><a href="#cb25-15" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||
<span id="cb25-16"><a href="#cb25-16" aria-hidden="true" tabindex="-1"></a>]</span></code></pre></div>
|
||||
<p><strong>Option B - Use Position-Based Tracking</strong>:</p>
|
||||
<div class="sourceCode" id="cb26"><pre
|
||||
class="sourceCode python"><code class="sourceCode python"><span id="cb26-1"><a href="#cb26-1" aria-hidden="true" tabindex="-1"></a><span class="co"># Remove sats-equivalent metadata entirely</span></span>
|
||||
<span id="cb26-2"><a href="#cb26-2" aria-hidden="true" tabindex="-1"></a>postings <span class="op">=</span> [</span>
|
||||
<span id="cb26-3"><a href="#cb26-3" aria-hidden="true" tabindex="-1"></a> {</span>
|
||||
<span id="cb26-4"><a href="#cb26-4" aria-hidden="true" tabindex="-1"></a> <span class="st">"account"</span>: payment_account,</span>
|
||||
<span id="cb26-5"><a href="#cb26-5" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</span><span class="sc">{</span><span class="bu">abs</span>(amount_sats)<span class="sc">}</span><span class="ss"> SATS @@ </span><span class="sc">{</span><span class="bu">abs</span>(net_fiat_amount)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">"</span>,</span>
|
||||
<span id="cb26-6"><a href="#cb26-6" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {<span class="st">"payment-hash"</span>: payment_hash}</span>
|
||||
<span id="cb26-7"><a href="#cb26-7" aria-hidden="true" tabindex="-1"></a> },</span>
|
||||
<span id="cb26-8"><a href="#cb26-8" aria-hidden="true" tabindex="-1"></a> {</span>
|
||||
<span id="cb26-9"><a href="#cb26-9" aria-hidden="true" tabindex="-1"></a> <span class="st">"account"</span>: receivable_account,</span>
|
||||
<span id="cb26-10"><a href="#cb26-10" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"-</span><span class="sc">{</span><span class="bu">abs</span>(total_receivable_fiat)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">"</span>,</span>
|
||||
<span id="cb26-11"><a href="#cb26-11" aria-hidden="true" tabindex="-1"></a> <span class="co"># No sats-equivalent needed - queryable via price database</span></span>
|
||||
<span id="cb26-12"><a href="#cb26-12" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||
<span id="cb26-13"><a href="#cb26-13" aria-hidden="true" tabindex="-1"></a>]</span></code></pre></div>
|
||||
<p><strong>Recommendation</strong>: Choose Option A (metadata) for
|
||||
consistency with Castle’s architecture.</p>
|
||||
<hr />
|
||||
<h4 id="rename-function-for-clarity">1.3 Rename Function for
|
||||
Clarity</h4>
|
||||
<p><strong>File</strong>: <code>beancount_format.py</code></p>
|
||||
<p><strong>Current</strong>:
|
||||
<code>format_net_settlement_entry()</code></p>
|
||||
<p><strong>New</strong>: <code>format_receivable_payment_entry()</code>
|
||||
or <code>format_payment_settlement_entry()</code></p>
|
||||
<p><strong>Rationale</strong>: More accurately describes what the
|
||||
function does (processes payments, not always net settlements)</p>
|
||||
<hr />
|
||||
<h3 id="priority-2-medium-term-improvements-compliance">Priority 2:
|
||||
Medium-Term Improvements (Compliance)</h3>
|
||||
<h4 id="add-exchange-gainloss-tracking">2.1 Add Exchange Gain/Loss
|
||||
Tracking</h4>
|
||||
<p><strong>File</strong>: <code>tasks.py:259-276</code> (get balance and
|
||||
calculate settlement)</p>
|
||||
<p><strong>New Logic</strong>:</p>
|
||||
<div class="sourceCode" id="cb27"><pre
|
||||
class="sourceCode python"><code class="sourceCode python"><span id="cb27-1"><a href="#cb27-1" aria-hidden="true" tabindex="-1"></a><span class="co"># Get user's current balance</span></span>
|
||||
<span id="cb27-2"><a href="#cb27-2" aria-hidden="true" tabindex="-1"></a>balance <span class="op">=</span> <span class="cf">await</span> fava.get_user_balance(user_id)</span>
|
||||
<span id="cb27-3"><a href="#cb27-3" aria-hidden="true" tabindex="-1"></a>fiat_balances <span class="op">=</span> balance.get(<span class="st">"fiat_balances"</span>, {})</span>
|
||||
<span id="cb27-4"><a href="#cb27-4" aria-hidden="true" tabindex="-1"></a>total_fiat_balance <span class="op">=</span> fiat_balances.get(fiat_currency, Decimal(<span class="dv">0</span>))</span>
|
||||
<span id="cb27-5"><a href="#cb27-5" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb27-6"><a href="#cb27-6" aria-hidden="true" tabindex="-1"></a><span class="co"># Calculate expected fiat value of SATS payment at current market rate</span></span>
|
||||
<span id="cb27-7"><a href="#cb27-7" aria-hidden="true" tabindex="-1"></a>market_rate <span class="op">=</span> <span class="cf">await</span> get_current_sats_eur_rate() <span class="co"># New function needed</span></span>
|
||||
<span id="cb27-8"><a href="#cb27-8" aria-hidden="true" tabindex="-1"></a>market_value <span class="op">=</span> Decimal(amount_sats) <span class="op">*</span> market_rate</span>
|
||||
<span id="cb27-9"><a href="#cb27-9" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb27-10"><a href="#cb27-10" aria-hidden="true" tabindex="-1"></a><span class="co"># Calculate exchange variance</span></span>
|
||||
<span id="cb27-11"><a href="#cb27-11" aria-hidden="true" tabindex="-1"></a>receivable_amount <span class="op">=</span> <span class="bu">abs</span>(total_fiat_balance) <span class="cf">if</span> total_fiat_balance <span class="op">></span> <span class="dv">0</span> <span class="cf">else</span> Decimal(<span class="dv">0</span>)</span>
|
||||
<span id="cb27-12"><a href="#cb27-12" aria-hidden="true" tabindex="-1"></a>exchange_variance <span class="op">=</span> market_value <span class="op">-</span> receivable_amount</span>
|
||||
<span id="cb27-13"><a href="#cb27-13" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb27-14"><a href="#cb27-14" aria-hidden="true" tabindex="-1"></a><span class="co"># If variance is material (> 1 cent), create exchange gain/loss posting</span></span>
|
||||
<span id="cb27-15"><a href="#cb27-15" aria-hidden="true" tabindex="-1"></a><span class="cf">if</span> <span class="bu">abs</span>(exchange_variance) <span class="op">></span> Decimal(<span class="st">"0.01"</span>):</span>
|
||||
<span id="cb27-16"><a href="#cb27-16" aria-hidden="true" tabindex="-1"></a> <span class="co"># Add exchange gain/loss to postings</span></span>
|
||||
<span id="cb27-17"><a href="#cb27-17" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> exchange_variance <span class="op">></span> <span class="dv">0</span>:</span>
|
||||
<span id="cb27-18"><a href="#cb27-18" aria-hidden="true" tabindex="-1"></a> <span class="co"># Gain: payment worth more than receivable</span></span>
|
||||
<span id="cb27-19"><a href="#cb27-19" aria-hidden="true" tabindex="-1"></a> exchange_account <span class="op">=</span> <span class="st">"Revenue:Foreign-Exchange-Gain"</span></span>
|
||||
<span id="cb27-20"><a href="#cb27-20" aria-hidden="true" tabindex="-1"></a> <span class="cf">else</span>:</span>
|
||||
<span id="cb27-21"><a href="#cb27-21" aria-hidden="true" tabindex="-1"></a> <span class="co"># Loss: payment worth less than receivable</span></span>
|
||||
<span id="cb27-22"><a href="#cb27-22" aria-hidden="true" tabindex="-1"></a> exchange_account <span class="op">=</span> <span class="st">"Expenses:Foreign-Exchange-Loss"</span></span>
|
||||
<span id="cb27-23"><a href="#cb27-23" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb27-24"><a href="#cb27-24" aria-hidden="true" tabindex="-1"></a> <span class="co"># Include in entry creation</span></span>
|
||||
<span id="cb27-25"><a href="#cb27-25" aria-hidden="true" tabindex="-1"></a> exchange_posting <span class="op">=</span> {</span>
|
||||
<span id="cb27-26"><a href="#cb27-26" aria-hidden="true" tabindex="-1"></a> <span class="st">"account"</span>: exchange_account,</span>
|
||||
<span id="cb27-27"><a href="#cb27-27" aria-hidden="true" tabindex="-1"></a> <span class="st">"amount"</span>: <span class="ss">f"</span><span class="sc">{</span><span class="bu">abs</span>(exchange_variance)<span class="sc">:.2f}</span><span class="ss"> </span><span class="sc">{</span>fiat_currency<span class="sc">}</span><span class="ss">"</span>,</span>
|
||||
<span id="cb27-28"><a href="#cb27-28" aria-hidden="true" tabindex="-1"></a> <span class="st">"meta"</span>: {</span>
|
||||
<span id="cb27-29"><a href="#cb27-29" aria-hidden="true" tabindex="-1"></a> <span class="st">"sats-amount"</span>: <span class="bu">str</span>(amount_sats),</span>
|
||||
<span id="cb27-30"><a href="#cb27-30" aria-hidden="true" tabindex="-1"></a> <span class="st">"market-rate"</span>: <span class="bu">str</span>(market_rate),</span>
|
||||
<span id="cb27-31"><a href="#cb27-31" aria-hidden="true" tabindex="-1"></a> <span class="st">"receivable-amount"</span>: <span class="bu">str</span>(receivable_amount)</span>
|
||||
<span id="cb27-32"><a href="#cb27-32" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||
<span id="cb27-33"><a href="#cb27-33" aria-hidden="true" tabindex="-1"></a> }</span></code></pre></div>
|
||||
<p><strong>Benefits</strong>: - ✅ Tax compliance - ✅ Accurate
|
||||
financial reporting - ✅ Audit trail for cryptocurrency gains/losses -
|
||||
✅ Regulatory compliance (GAAP/IFRS)</p>
|
||||
<hr />
|
||||
<h4 id="implement-true-net-settlement-vs.-simple-payment-logic">2.2
|
||||
Implement True Net Settlement vs. Simple Payment Logic</h4>
|
||||
<p><strong>File</strong>: <code>tasks.py</code> or new
|
||||
<code>payment_logic.py</code></p>
|
||||
<div class="sourceCode" id="cb28"><pre
|
||||
class="sourceCode python"><code class="sourceCode python"><span id="cb28-1"><a href="#cb28-1" aria-hidden="true" tabindex="-1"></a><span class="cf">async</span> <span class="kw">def</span> create_payment_entry(</span>
|
||||
<span id="cb28-2"><a href="#cb28-2" aria-hidden="true" tabindex="-1"></a> user_id: <span class="bu">str</span>,</span>
|
||||
<span id="cb28-3"><a href="#cb28-3" aria-hidden="true" tabindex="-1"></a> amount_sats: <span class="bu">int</span>,</span>
|
||||
<span id="cb28-4"><a href="#cb28-4" aria-hidden="true" tabindex="-1"></a> fiat_amount: Decimal,</span>
|
||||
<span id="cb28-5"><a href="#cb28-5" aria-hidden="true" tabindex="-1"></a> fiat_currency: <span class="bu">str</span>,</span>
|
||||
<span id="cb28-6"><a href="#cb28-6" aria-hidden="true" tabindex="-1"></a> payment_hash: <span class="bu">str</span></span>
|
||||
<span id="cb28-7"><a href="#cb28-7" aria-hidden="true" tabindex="-1"></a>):</span>
|
||||
<span id="cb28-8"><a href="#cb28-8" aria-hidden="true" tabindex="-1"></a> <span class="co">"""</span></span>
|
||||
<span id="cb28-9"><a href="#cb28-9" aria-hidden="true" tabindex="-1"></a><span class="co"> Create appropriate payment entry based on user's balance situation.</span></span>
|
||||
<span id="cb28-10"><a href="#cb28-10" aria-hidden="true" tabindex="-1"></a><span class="co"> Uses 2-posting for simple payments, 3-posting for net settlements.</span></span>
|
||||
<span id="cb28-11"><a href="#cb28-11" aria-hidden="true" tabindex="-1"></a><span class="co"> """</span></span>
|
||||
<span id="cb28-12"><a href="#cb28-12" aria-hidden="true" tabindex="-1"></a> <span class="co"># Get user balance</span></span>
|
||||
<span id="cb28-13"><a href="#cb28-13" aria-hidden="true" tabindex="-1"></a> balance <span class="op">=</span> <span class="cf">await</span> fava.get_user_balance(user_id)</span>
|
||||
<span id="cb28-14"><a href="#cb28-14" aria-hidden="true" tabindex="-1"></a> fiat_balances <span class="op">=</span> balance.get(<span class="st">"fiat_balances"</span>, {})</span>
|
||||
<span id="cb28-15"><a href="#cb28-15" aria-hidden="true" tabindex="-1"></a> total_balance <span class="op">=</span> fiat_balances.get(fiat_currency, Decimal(<span class="dv">0</span>))</span>
|
||||
<span id="cb28-16"><a href="#cb28-16" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb28-17"><a href="#cb28-17" aria-hidden="true" tabindex="-1"></a> receivable_amount <span class="op">=</span> Decimal(<span class="dv">0</span>)</span>
|
||||
<span id="cb28-18"><a href="#cb28-18" aria-hidden="true" tabindex="-1"></a> payable_amount <span class="op">=</span> Decimal(<span class="dv">0</span>)</span>
|
||||
<span id="cb28-19"><a href="#cb28-19" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb28-20"><a href="#cb28-20" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> total_balance <span class="op">></span> <span class="dv">0</span>:</span>
|
||||
<span id="cb28-21"><a href="#cb28-21" aria-hidden="true" tabindex="-1"></a> receivable_amount <span class="op">=</span> total_balance</span>
|
||||
<span id="cb28-22"><a href="#cb28-22" aria-hidden="true" tabindex="-1"></a> <span class="cf">elif</span> total_balance <span class="op"><</span> <span class="dv">0</span>:</span>
|
||||
<span id="cb28-23"><a href="#cb28-23" aria-hidden="true" tabindex="-1"></a> payable_amount <span class="op">=</span> <span class="bu">abs</span>(total_balance)</span>
|
||||
<span id="cb28-24"><a href="#cb28-24" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb28-25"><a href="#cb28-25" aria-hidden="true" tabindex="-1"></a> <span class="co"># Determine entry type</span></span>
|
||||
<span id="cb28-26"><a href="#cb28-26" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> receivable_amount <span class="op">></span> <span class="dv">0</span> <span class="kw">and</span> payable_amount <span class="op">></span> <span class="dv">0</span>:</span>
|
||||
<span id="cb28-27"><a href="#cb28-27" aria-hidden="true" tabindex="-1"></a> <span class="co"># TRUE NET SETTLEMENT: Both obligations exist</span></span>
|
||||
<span id="cb28-28"><a href="#cb28-28" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span> <span class="cf">await</span> format_net_settlement_entry(</span>
|
||||
<span id="cb28-29"><a href="#cb28-29" aria-hidden="true" tabindex="-1"></a> user_id<span class="op">=</span>user_id,</span>
|
||||
<span id="cb28-30"><a href="#cb28-30" aria-hidden="true" tabindex="-1"></a> amount_sats<span class="op">=</span>amount_sats,</span>
|
||||
<span id="cb28-31"><a href="#cb28-31" aria-hidden="true" tabindex="-1"></a> receivable_amount<span class="op">=</span>receivable_amount,</span>
|
||||
<span id="cb28-32"><a href="#cb28-32" aria-hidden="true" tabindex="-1"></a> payable_amount<span class="op">=</span>payable_amount,</span>
|
||||
<span id="cb28-33"><a href="#cb28-33" aria-hidden="true" tabindex="-1"></a> fiat_amount<span class="op">=</span>fiat_amount,</span>
|
||||
<span id="cb28-34"><a href="#cb28-34" aria-hidden="true" tabindex="-1"></a> fiat_currency<span class="op">=</span>fiat_currency,</span>
|
||||
<span id="cb28-35"><a href="#cb28-35" aria-hidden="true" tabindex="-1"></a> payment_hash<span class="op">=</span>payment_hash</span>
|
||||
<span id="cb28-36"><a href="#cb28-36" aria-hidden="true" tabindex="-1"></a> )</span>
|
||||
<span id="cb28-37"><a href="#cb28-37" aria-hidden="true" tabindex="-1"></a> <span class="cf">elif</span> receivable_amount <span class="op">></span> <span class="dv">0</span>:</span>
|
||||
<span id="cb28-38"><a href="#cb28-38" aria-hidden="true" tabindex="-1"></a> <span class="co"># SIMPLE RECEIVABLE PAYMENT: Only receivable exists</span></span>
|
||||
<span id="cb28-39"><a href="#cb28-39" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span> <span class="cf">await</span> format_receivable_payment_entry(</span>
|
||||
<span id="cb28-40"><a href="#cb28-40" aria-hidden="true" tabindex="-1"></a> user_id<span class="op">=</span>user_id,</span>
|
||||
<span id="cb28-41"><a href="#cb28-41" aria-hidden="true" tabindex="-1"></a> amount_sats<span class="op">=</span>amount_sats,</span>
|
||||
<span id="cb28-42"><a href="#cb28-42" aria-hidden="true" tabindex="-1"></a> receivable_amount<span class="op">=</span>receivable_amount,</span>
|
||||
<span id="cb28-43"><a href="#cb28-43" aria-hidden="true" tabindex="-1"></a> fiat_amount<span class="op">=</span>fiat_amount,</span>
|
||||
<span id="cb28-44"><a href="#cb28-44" aria-hidden="true" tabindex="-1"></a> fiat_currency<span class="op">=</span>fiat_currency,</span>
|
||||
<span id="cb28-45"><a href="#cb28-45" aria-hidden="true" tabindex="-1"></a> payment_hash<span class="op">=</span>payment_hash</span>
|
||||
<span id="cb28-46"><a href="#cb28-46" aria-hidden="true" tabindex="-1"></a> )</span>
|
||||
<span id="cb28-47"><a href="#cb28-47" aria-hidden="true" tabindex="-1"></a> <span class="cf">else</span>:</span>
|
||||
<span id="cb28-48"><a href="#cb28-48" aria-hidden="true" tabindex="-1"></a> <span class="co"># PAYABLE PAYMENT: Castle paying user (different flow)</span></span>
|
||||
<span id="cb28-49"><a href="#cb28-49" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span> <span class="cf">await</span> format_payable_payment_entry(...)</span></code></pre></div>
|
||||
<hr />
|
||||
<h3 id="priority-3-long-term-architectural-decisions">Priority 3:
|
||||
Long-Term Architectural Decisions</h3>
|
||||
<h4 id="establish-primary-currency-hierarchy">3.1 Establish Primary
|
||||
Currency Hierarchy</h4>
|
||||
<p><strong>Current Issue</strong>: Mixed approach (EUR positions with
|
||||
SATS metadata, but also SATS positions with @ notation)</p>
|
||||
<p><strong>Decision Required</strong>: Choose ONE of the following
|
||||
architectures:</p>
|
||||
<p><strong>Architecture A - EUR Primary, SATS Secondary</strong>
|
||||
(recommended):</p>
|
||||
<pre class="beancount"><code>; 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"</code></pre>
|
||||
<p><strong>Architecture B - SATS Primary, EUR Secondary</strong>:</p>
|
||||
<pre class="beancount"><code>; 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"</code></pre>
|
||||
<p><strong>Recommendation</strong>: 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</p>
|
||||
<hr />
|
||||
<h4 id="consider-separate-ledger-for-cryptocurrency-holdings">3.2
|
||||
Consider Separate Ledger for Cryptocurrency Holdings</h4>
|
||||
<p><strong>Advanced Approach</strong>: Separate cryptocurrency movements
|
||||
from fiat accounting</p>
|
||||
<p><strong>Main Ledger</strong> (EUR-denominated):</p>
|
||||
<pre class="beancount"><code>2025-11-12 * "Payment received from user"
|
||||
Assets:Bitcoin-Custody:User-375ec158 200.00 EUR
|
||||
Assets:Receivable:User-375ec158 -200.00 EUR</code></pre>
|
||||
<p><strong>Cryptocurrency Sub-Ledger</strong> (SATS-denominated):</p>
|
||||
<pre class="beancount"><code>2025-11-12 * "Lightning payment received"
|
||||
Assets:Bitcoin:Lightning:Castle 225033 SATS
|
||||
Assets:Bitcoin:Custody:User-375ec 225033 SATS</code></pre>
|
||||
<p><strong>Benefits</strong>: - ✅ Clean separation of concerns - ✅
|
||||
Cryptocurrency movements tracked independently - ✅ Fiat accounting
|
||||
unaffected by Bitcoin volatility - ✅ Can generate separate financial
|
||||
statements</p>
|
||||
<p><strong>Drawbacks</strong>: - ❌ Increased complexity - ❌
|
||||
Reconciliation between ledgers required - ❌ Two sets of books to
|
||||
maintain</p>
|
||||
<hr />
|
||||
<h2 id="code-files-requiring-changes">Code Files Requiring Changes</h2>
|
||||
<h3 id="high-priority-immediate-fixes">High Priority (Immediate
|
||||
Fixes)</h3>
|
||||
<ol type="1">
|
||||
<li><strong><code>beancount_format.py:739-760</code></strong>
|
||||
<ul>
|
||||
<li>Remove zero-amount postings</li>
|
||||
<li>Make payable posting conditional</li>
|
||||
</ul></li>
|
||||
<li><strong><code>beancount_format.py:692</code></strong>
|
||||
<ul>
|
||||
<li>Rename function to <code>format_receivable_payment_entry</code></li>
|
||||
</ul></li>
|
||||
</ol>
|
||||
<h3 id="medium-priority-compliance">Medium Priority (Compliance)</h3>
|
||||
<ol start="3" type="1">
|
||||
<li><strong><code>tasks.py:235-310</code></strong>
|
||||
<ul>
|
||||
<li>Add exchange gain/loss calculation</li>
|
||||
<li>Implement payment vs. settlement logic</li>
|
||||
</ul></li>
|
||||
<li><strong>New file: <code>exchange_rates.py</code></strong>
|
||||
<ul>
|
||||
<li>Create <code>get_current_sats_eur_rate()</code> function</li>
|
||||
<li>Implement price feed integration</li>
|
||||
</ul></li>
|
||||
<li><strong><code>beancount_format.py</code></strong>
|
||||
<ul>
|
||||
<li>Create new <code>format_net_settlement_entry()</code> for true
|
||||
netting</li>
|
||||
<li>Create <code>format_receivable_payment_entry()</code> for simple
|
||||
payments</li>
|
||||
</ul></li>
|
||||
</ol>
|
||||
<hr />
|
||||
<h2 id="testing-requirements">Testing Requirements</h2>
|
||||
<h3 id="test-case-1-simple-receivable-payment-no-payable">Test Case 1:
|
||||
Simple Receivable Payment (No Payable)</h3>
|
||||
<p><strong>Setup</strong>: - User has receivable: 200.00 EUR - User has
|
||||
payable: 0.00 EUR - User pays: 225,033 SATS</p>
|
||||
<p><strong>Expected Entry</strong> (after fixes):</p>
|
||||
<pre class="beancount"><code>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"</code></pre>
|
||||
<p><strong>Verify</strong>: - ✅ Only 2 postings (no zero-amount
|
||||
payable) - ✅ Entry balances - ✅ SATS tracked in metadata - ✅ User
|
||||
balance becomes 0 (both EUR and SATS)</p>
|
||||
<hr />
|
||||
<h3 id="test-case-2-true-net-settlement">Test Case 2: True Net
|
||||
Settlement</h3>
|
||||
<p><strong>Setup</strong>: - 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)</p>
|
||||
<p><strong>Expected Entry</strong>:</p>
|
||||
<pre class="beancount"><code>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</code></pre>
|
||||
<p><strong>Verify</strong>: - ✅ 3 postings (receivable + payable
|
||||
cleared) - ✅ Net amount = receivable - payable - ✅ Both balances
|
||||
become 0 - ✅ Mathematically balanced</p>
|
||||
<hr />
|
||||
<h3 id="test-case-3-exchange-gainloss-future">Test Case 3: Exchange
|
||||
Gain/Loss (Future)</h3>
|
||||
<p><strong>Setup</strong>: - 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</p>
|
||||
<p><strong>Expected Entry</strong> (with exchange tracking):</p>
|
||||
<pre class="beancount"><code>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</code></pre>
|
||||
<p><strong>Verify</strong>: - ✅ Bitcoin recorded at fair market value -
|
||||
✅ Exchange loss recognized - ✅ Receivable cleared at book value - ✅
|
||||
Entry balances</p>
|
||||
<hr />
|
||||
<h2 id="conclusion">Conclusion</h2>
|
||||
<h3 id="summary-of-issues">Summary of Issues</h3>
|
||||
<table>
|
||||
<colgroup>
|
||||
<col style="width: 12%" />
|
||||
<col style="width: 18%" />
|
||||
<col style="width: 34%" />
|
||||
<col style="width: 34%" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Issue</th>
|
||||
<th>Severity</th>
|
||||
<th>Accounting Impact</th>
|
||||
<th>Recommended Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Zero-amount postings</td>
|
||||
<td>Low</td>
|
||||
<td>Presentation only</td>
|
||||
<td>Remove immediately</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Redundant SATS tracking</td>
|
||||
<td>Low</td>
|
||||
<td>Storage/efficiency</td>
|
||||
<td>Choose one method</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>No exchange gain/loss</td>
|
||||
<td><strong>High</strong></td>
|
||||
<td>Financial accuracy</td>
|
||||
<td>Implement for compliance</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Semantic misuse of @</td>
|
||||
<td>Medium</td>
|
||||
<td>Audit clarity</td>
|
||||
<td>Consider EUR-only positions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Misnamed function</td>
|
||||
<td>Low</td>
|
||||
<td>Code clarity</td>
|
||||
<td>Rename function</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3 id="professional-assessment">Professional Assessment</h3>
|
||||
<p><strong>Is this “best practice” accounting?</strong>
|
||||
<strong>No</strong>, this implementation deviates from traditional
|
||||
accounting standards in several ways.</p>
|
||||
<p><strong>Is it acceptable for Castle’s use case?</strong> <strong>Yes,
|
||||
with modifications</strong>, it’s a reasonable pragmatic solution for a
|
||||
novel problem (cryptocurrency payments of fiat debts).</p>
|
||||
<p><strong>Critical improvements needed</strong>: 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)</p>
|
||||
<p><strong>The fundamental challenge</strong>: 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.</p>
|
||||
<h3 id="next-steps">Next Steps</h3>
|
||||
<ol type="1">
|
||||
<li><strong>Week 1</strong>: Implement Priority 1 fixes (remove zero
|
||||
postings, rename function)</li>
|
||||
<li><strong>Week 2-3</strong>: Design and implement exchange gain/loss
|
||||
tracking</li>
|
||||
<li><strong>Week 4</strong>: Add payment vs. settlement logic</li>
|
||||
<li><strong>Ongoing</strong>: Monitor regulatory guidance on
|
||||
cryptocurrency accounting</li>
|
||||
</ol>
|
||||
<hr />
|
||||
<h2 id="references">References</h2>
|
||||
<ul>
|
||||
<li><strong>FASB ASC 830</strong>: Foreign Currency Matters</li>
|
||||
<li><strong>IAS 21</strong>: The Effects of Changes in Foreign Exchange
|
||||
Rates</li>
|
||||
<li><strong>FASB Concept Statement No. 2</strong>: Qualitative
|
||||
Characteristics of Accounting Information</li>
|
||||
<li><strong>ASC 105-10-05</strong>: Substance Over Form</li>
|
||||
<li><strong>Beancount Documentation</strong>:
|
||||
http://furius.ca/beancount/doc/index</li>
|
||||
<li><strong>Castle Extension</strong>:
|
||||
<code>docs/SATS-EQUIVALENT-METADATA.md</code></li>
|
||||
<li><strong>BQL Analysis</strong>:
|
||||
<code>docs/BQL-BALANCE-QUERIES.md</code></li>
|
||||
</ul>
|
||||
<hr />
|
||||
<p><strong>Document Version</strong>: 1.0 <strong>Last Updated</strong>:
|
||||
2025-01-12 <strong>Next Review</strong>: After Priority 1 fixes
|
||||
implemented</p>
|
||||
<hr />
|
||||
<p><em>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.</em></p>
|
||||
</body>
|
||||
</html>
|
||||
861
docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.md
Normal file
861
docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.md
Normal file
|
|
@ -0,0 +1,861 @@
|
|||
# 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.*
|
||||
529
docs/BQL-PRICE-NOTATION-SOLUTION.md
Normal file
529
docs/BQL-PRICE-NOTATION-SOLUTION.md
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
# 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 <input.beancount> <output.beancount>")
|
||||
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
|
||||
386
docs/SATS-EQUIVALENT-METADATA.md
Normal file
386
docs/SATS-EQUIVALENT-METADATA.md
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
# 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue