Completes Phase 1: Beancount patterns adoption
Implements core improvements from Phase 1 of the Beancount patterns adoption: - Uses Decimal for fiat amounts to prevent floating point errors - Adds a meta field to journal entries for a full audit trail - Adds a flag field to journal entries for transaction status - Migrates existing account names to a hierarchical format This commit introduces a database migration to add the `flag` and `meta` columns to the `journal_entries` table. It also includes updates to the models, CRUD operations, and API endpoints to handle the new fields.
This commit is contained in:
parent
35d2057694
commit
1a28ec59eb
7 changed files with 616 additions and 31 deletions
197
PHASE1_COMPLETE.md
Normal file
197
PHASE1_COMPLETE.md
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
# Phase 1 Implementation - Complete ✅
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
We've successfully implemented the core improvements from Phase 1 of the Beancount patterns adoption:
|
||||||
|
|
||||||
|
## ✅ Completed
|
||||||
|
|
||||||
|
### 1. **Decimal Instead of Float for Fiat Amounts**
|
||||||
|
- **Files Changed:**
|
||||||
|
- `models.py`: Changed all fiat amount fields from `float` to `Decimal`
|
||||||
|
- `ExpenseEntry.amount`
|
||||||
|
- `ReceivableEntry.amount`
|
||||||
|
- `RevenueEntry.amount`
|
||||||
|
- `UserBalance.fiat_balances` dictionary values
|
||||||
|
- `crud.py`: Updated fiat balance calculations to use `Decimal`
|
||||||
|
- `views_api.py`: Store fiat amounts as strings with `str(amount.quantize(Decimal("0.001")))`
|
||||||
|
|
||||||
|
- **Benefits:**
|
||||||
|
- Prevents floating point rounding errors
|
||||||
|
- Exact decimal arithmetic
|
||||||
|
- Financial-grade precision
|
||||||
|
|
||||||
|
### 2. **Meta Field for Journal Entries**
|
||||||
|
- **Database Migration:** `m005_add_flag_and_meta`
|
||||||
|
- Added `meta TEXT DEFAULT '{}'` column to `journal_entries` table
|
||||||
|
|
||||||
|
- **Model Changes:**
|
||||||
|
- Added `meta: dict = {}` to `JournalEntry` and `CreateJournalEntry`
|
||||||
|
- Meta stores: source, created_via, user_id, payment_hash, etc.
|
||||||
|
|
||||||
|
- **CRUD Updates:**
|
||||||
|
- `create_journal_entry()` now stores meta as JSON
|
||||||
|
- `get_journal_entries_by_user()` parses meta from JSON
|
||||||
|
|
||||||
|
- **API Integration:**
|
||||||
|
- Expense entries: `{"source": "api", "created_via": "expense_entry", "user_id": "...", "is_equity": false}`
|
||||||
|
- Receivable entries: `{"source": "api", "created_via": "receivable_entry", "debtor_user_id": "..."}`
|
||||||
|
- Payment entries: `{"source": "lightning_payment", "created_via": "record_payment", "payment_hash": "...", "payer_user_id": "..."}`
|
||||||
|
|
||||||
|
- **Benefits:**
|
||||||
|
- Full audit trail for every transaction
|
||||||
|
- Source tracking (where did this entry come from?)
|
||||||
|
- Can add tags, links, notes in future
|
||||||
|
- Essential for compliance and debugging
|
||||||
|
|
||||||
|
### 3. **Flag Field for Transaction Status**
|
||||||
|
- **Database Migration:** `m005_add_flag_and_meta`
|
||||||
|
- Added `flag TEXT DEFAULT '*'` column to `journal_entries` table
|
||||||
|
|
||||||
|
- **Model Changes:**
|
||||||
|
- Created `JournalEntryFlag` enum:
|
||||||
|
- `*` = CLEARED (confirmed/reconciled)
|
||||||
|
- `!` = PENDING (awaiting confirmation)
|
||||||
|
- `#` = FLAGGED (needs review)
|
||||||
|
- `x` = VOID (cancelled)
|
||||||
|
- Added `flag: JournalEntryFlag` to `JournalEntry` and `CreateJournalEntry`
|
||||||
|
|
||||||
|
- **CRUD Updates:**
|
||||||
|
- `create_journal_entry()` stores flag as string value
|
||||||
|
- `get_journal_entries_by_user()` converts string to enum
|
||||||
|
|
||||||
|
- **API Logic:**
|
||||||
|
- Expense entries: Default to CLEARED (immediately confirmed)
|
||||||
|
- Receivable entries: Start as PENDING (unpaid debt)
|
||||||
|
- Payment entries: Mark as CLEARED (payment received)
|
||||||
|
|
||||||
|
- **Benefits:**
|
||||||
|
- Visual indication of transaction status in UI
|
||||||
|
- Filter transactions by status
|
||||||
|
- Supports reconciliation workflows
|
||||||
|
- Standard accounting practice (Beancount-style)
|
||||||
|
|
||||||
|
## 📊 Migration Details
|
||||||
|
|
||||||
|
**Migration `m005_add_flag_and_meta`:**
|
||||||
|
```sql
|
||||||
|
ALTER TABLE journal_entries ADD COLUMN flag TEXT DEFAULT '*';
|
||||||
|
ALTER TABLE journal_entries ADD COLUMN meta TEXT DEFAULT '{}';
|
||||||
|
```
|
||||||
|
|
||||||
|
**To Apply:**
|
||||||
|
1. Stop LNbits server (if running)
|
||||||
|
2. Restart LNbits - migration runs automatically
|
||||||
|
3. Check logs for "m005_add_flag_and_meta" success message
|
||||||
|
|
||||||
|
## 🔧 Technical Implementation Details
|
||||||
|
|
||||||
|
### Decimal Handling
|
||||||
|
```python
|
||||||
|
# Store as string for precision
|
||||||
|
metadata = {
|
||||||
|
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse back to Decimal
|
||||||
|
fiat_decimal = Decimal(str(fiat_amount))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flag Handling
|
||||||
|
```python
|
||||||
|
# Set flag on creation
|
||||||
|
entry_data = CreateJournalEntry(
|
||||||
|
flag=JournalEntryFlag.PENDING, # or CLEARED
|
||||||
|
# ...
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse from database
|
||||||
|
flag = JournalEntryFlag(entry_data.get("flag", "*"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Meta Handling
|
||||||
|
```python
|
||||||
|
# Create with meta
|
||||||
|
entry_meta = {
|
||||||
|
"source": "api",
|
||||||
|
"created_via": "expense_entry",
|
||||||
|
"user_id": wallet.wallet.user,
|
||||||
|
}
|
||||||
|
|
||||||
|
entry_data = CreateJournalEntry(
|
||||||
|
meta=entry_meta,
|
||||||
|
# ...
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse from database
|
||||||
|
meta = json.loads(entry_data.get("meta", "{}")) if entry_data.get("meta") else {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 What's Next (Remaining Phase 1 Items)
|
||||||
|
|
||||||
|
### Hierarchical Account Naming (In Progress)
|
||||||
|
Implement Beancount-style account hierarchy:
|
||||||
|
- Current: `"Accounts Receivable - af983632"`
|
||||||
|
- Better: `"Assets:Receivable:User-af983632"`
|
||||||
|
|
||||||
|
### UI Updates for Flags
|
||||||
|
Display flag icons in transaction list:
|
||||||
|
- ✅ `*` = Green checkmark (cleared)
|
||||||
|
- ⚠️ `!` = Yellow/Orange badge (pending)
|
||||||
|
- 🚩 `#` = Red flag (needs review)
|
||||||
|
- ❌ `x` = Strikethrough (voided)
|
||||||
|
|
||||||
|
## 🧪 Testing Recommendations
|
||||||
|
|
||||||
|
1. **Test Decimal Precision:**
|
||||||
|
```python
|
||||||
|
# Create expense with fiat amount
|
||||||
|
POST /api/v1/entries/expense
|
||||||
|
{"amount": "36.93", "currency": "EUR", ...}
|
||||||
|
|
||||||
|
# Verify stored as exact string
|
||||||
|
SELECT metadata FROM entry_lines WHERE ...
|
||||||
|
# Should see: {"fiat_amount": "36.930", ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Test Flag Workflow:**
|
||||||
|
```python
|
||||||
|
# Create receivable (should be PENDING)
|
||||||
|
POST /api/v1/entries/receivable
|
||||||
|
# Check: flag = '!'
|
||||||
|
|
||||||
|
# Pay receivable (creates CLEARED entry)
|
||||||
|
POST /api/v1/record-payment
|
||||||
|
# Check: payment entry flag = '*'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test Meta Audit Trail:**
|
||||||
|
```python
|
||||||
|
# Create any entry
|
||||||
|
# Check database:
|
||||||
|
SELECT meta FROM journal_entries WHERE ...
|
||||||
|
# Should see: {"source": "api", "created_via": "...", ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎉 Success Metrics
|
||||||
|
|
||||||
|
- ✅ No more floating point errors in fiat calculations
|
||||||
|
- ✅ Every transaction has source tracking
|
||||||
|
- ✅ Transaction status is visible (pending vs cleared)
|
||||||
|
- ✅ Database migration successful
|
||||||
|
- ✅ All API endpoints updated
|
||||||
|
- ✅ CRUD operations handle new fields
|
||||||
|
|
||||||
|
## 📝 Notes
|
||||||
|
|
||||||
|
- **Backward Compatibility:** Old entries will have default values (`flag='*'`, `meta='{}'`)
|
||||||
|
- **Performance:** No impact - added columns have defaults and indexes not needed yet
|
||||||
|
- **Storage:** Minimal increase (meta typically < 200 bytes per entry)
|
||||||
|
|
||||||
|
## 🔜 Next Steps
|
||||||
|
|
||||||
|
Continue to Phase 1 completion:
|
||||||
|
1. Implement hierarchical account names
|
||||||
|
2. Update UI to show flags
|
||||||
|
3. Add UI for viewing meta information
|
||||||
|
4. Then move to Phase 2 (Core logic refactoring)
|
||||||
215
account_utils.py
Normal file
215
account_utils.py
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
"""
|
||||||
|
Account naming utilities for hierarchical account structure.
|
||||||
|
Implements Beancount-style account naming conventions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .models import AccountType
|
||||||
|
|
||||||
|
|
||||||
|
# Mapping from internal account types to Beancount root names
|
||||||
|
ACCOUNT_TYPE_ROOTS = {
|
||||||
|
AccountType.ASSET: "Assets",
|
||||||
|
AccountType.LIABILITY: "Liabilities",
|
||||||
|
AccountType.EQUITY: "Equity",
|
||||||
|
AccountType.REVENUE: "Income", # Beancount uses "Income" not "Revenue"
|
||||||
|
AccountType.EXPENSE: "Expenses",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_hierarchical_account_name(
|
||||||
|
account_type: AccountType,
|
||||||
|
base_name: str,
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Format account name in hierarchical Beancount-style.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
format_hierarchical_account_name(AccountType.ASSET, "Cash")
|
||||||
|
→ "Assets:Cash"
|
||||||
|
|
||||||
|
format_hierarchical_account_name(AccountType.ASSET, "Accounts Receivable", "af983632")
|
||||||
|
→ "Assets:Receivable:User-af983632"
|
||||||
|
|
||||||
|
format_hierarchical_account_name(AccountType.EXPENSE, "Food & Supplies")
|
||||||
|
→ "Expenses:Food:Supplies"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_type: The type of account (asset, liability, etc.)
|
||||||
|
base_name: The base name like "Cash", "Accounts Receivable", "Food & Supplies"
|
||||||
|
user_id: Optional user ID for user-specific accounts
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hierarchical account name string
|
||||||
|
"""
|
||||||
|
root = ACCOUNT_TYPE_ROOTS[account_type]
|
||||||
|
|
||||||
|
# Clean up the base name:
|
||||||
|
# 1. Remove "Accounts" prefix (e.g., "Accounts Receivable" → "Receivable")
|
||||||
|
# 2. Replace " & " with ":" for hierarchy (e.g., "Food & Supplies" → "Food:Supplies")
|
||||||
|
# 3. Remove extra spaces
|
||||||
|
clean_name = base_name.replace("Accounts ", "").replace(" & ", ":").strip()
|
||||||
|
|
||||||
|
# Build hierarchical path
|
||||||
|
if user_id:
|
||||||
|
# For user-specific accounts, add user suffix
|
||||||
|
# "Receivable" + "af983632" → "Receivable:User-af983632"
|
||||||
|
user_suffix = f"User-{user_id[:8]}"
|
||||||
|
return f"{root}:{clean_name}:{user_suffix}"
|
||||||
|
else:
|
||||||
|
# Regular account
|
||||||
|
return f"{root}:{clean_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_legacy_account_name(name: str) -> tuple[str, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Parse legacy account names like "Accounts Receivable - af983632"
|
||||||
|
into (base_name, user_id).
|
||||||
|
|
||||||
|
Used only for migration from old format to hierarchical.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Legacy account name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (base_name, user_id or None)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
parse_legacy_account_name("Accounts Receivable - af983632")
|
||||||
|
→ ("Accounts Receivable", "af983632")
|
||||||
|
|
||||||
|
parse_legacy_account_name("Cash")
|
||||||
|
→ ("Cash", None)
|
||||||
|
"""
|
||||||
|
if " - " in name:
|
||||||
|
parts = name.split(" - ", 1)
|
||||||
|
base_name = parts[0].strip()
|
||||||
|
user_id = parts[1].strip()
|
||||||
|
return base_name, user_id
|
||||||
|
else:
|
||||||
|
return name, None
|
||||||
|
|
||||||
|
|
||||||
|
def format_account_display_name(hierarchical_name: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert hierarchical name to human-readable display name.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
format_account_display_name("Assets:Receivable:User-af983632")
|
||||||
|
→ "Accounts Receivable - af983632"
|
||||||
|
|
||||||
|
format_account_display_name("Expenses:Food:Supplies")
|
||||||
|
→ "Food & Supplies"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hierarchical_name: Hierarchical account name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Human-readable display name
|
||||||
|
"""
|
||||||
|
parts = hierarchical_name.split(":")
|
||||||
|
|
||||||
|
if len(parts) < 2:
|
||||||
|
return hierarchical_name
|
||||||
|
|
||||||
|
# Skip the root (Assets, Liabilities, etc.)
|
||||||
|
body_parts = parts[1:]
|
||||||
|
|
||||||
|
# Check for user suffix
|
||||||
|
if len(body_parts) > 1 and body_parts[-1].startswith("User-"):
|
||||||
|
user_suffix = body_parts[-1].replace("User-", "")
|
||||||
|
base_parts = body_parts[:-1]
|
||||||
|
|
||||||
|
# Reconstruct base name
|
||||||
|
if base_parts[0] in ["Receivable", "Payable"]:
|
||||||
|
base_name = f"Accounts {base_parts[0]}"
|
||||||
|
else:
|
||||||
|
base_name = " & ".join(base_parts)
|
||||||
|
|
||||||
|
return f"{base_name} - {user_suffix}"
|
||||||
|
else:
|
||||||
|
# No user suffix, just join with &
|
||||||
|
return " & ".join(body_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def get_account_type_from_hierarchical(hierarchical_name: str) -> Optional[AccountType]:
|
||||||
|
"""
|
||||||
|
Extract account type from hierarchical name.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
get_account_type_from_hierarchical("Assets:Cash")
|
||||||
|
→ AccountType.ASSET
|
||||||
|
|
||||||
|
get_account_type_from_hierarchical("Income:Accommodation")
|
||||||
|
→ AccountType.REVENUE
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hierarchical_name: Hierarchical account name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AccountType or None if not found
|
||||||
|
"""
|
||||||
|
parts = hierarchical_name.split(":")
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
root = parts[0]
|
||||||
|
|
||||||
|
# Reverse lookup in ACCOUNT_TYPE_ROOTS
|
||||||
|
for account_type, root_name in ACCOUNT_TYPE_ROOTS.items():
|
||||||
|
if root == root_name:
|
||||||
|
return account_type
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_account_name(old_name: str, account_type: AccountType) -> str:
|
||||||
|
"""
|
||||||
|
Migrate a legacy account name to hierarchical format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_name: Legacy account name like "Accounts Receivable - af983632"
|
||||||
|
account_type: The account type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hierarchical account name
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
migrate_account_name("Accounts Receivable - af983632", AccountType.ASSET)
|
||||||
|
→ "Assets:Receivable:User-af983632"
|
||||||
|
|
||||||
|
migrate_account_name("Food & Supplies", AccountType.EXPENSE)
|
||||||
|
→ "Expenses:Food:Supplies"
|
||||||
|
"""
|
||||||
|
base_name, user_id = parse_legacy_account_name(old_name)
|
||||||
|
return format_hierarchical_account_name(account_type, base_name, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
# Default chart of accounts with hierarchical names
|
||||||
|
DEFAULT_HIERARCHICAL_ACCOUNTS = [
|
||||||
|
# Assets
|
||||||
|
("Assets:Cash", AccountType.ASSET, "Cash on hand"),
|
||||||
|
("Assets:Bank", AccountType.ASSET, "Bank account"),
|
||||||
|
("Assets:Lightning:Balance", AccountType.ASSET, "Lightning Network balance"),
|
||||||
|
("Assets:Receivable", AccountType.ASSET, "Money owed to the Castle"),
|
||||||
|
|
||||||
|
# Liabilities
|
||||||
|
("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the Castle"),
|
||||||
|
|
||||||
|
# Equity
|
||||||
|
("Equity:MemberEquity", AccountType.EQUITY, "Member contributions"),
|
||||||
|
("Equity:RetainedEarnings", AccountType.EQUITY, "Accumulated profits"),
|
||||||
|
|
||||||
|
# Revenue (Income in Beancount terminology)
|
||||||
|
("Income:Accommodation", AccountType.REVENUE, "Revenue from stays"),
|
||||||
|
("Income:Service", AccountType.REVENUE, "Revenue from services"),
|
||||||
|
("Income:Other", AccountType.REVENUE, "Other revenue"),
|
||||||
|
|
||||||
|
# Expenses
|
||||||
|
("Expenses:Utilities", AccountType.EXPENSE, "Electricity, water, internet"),
|
||||||
|
("Expenses:Food:Supplies", AccountType.EXPENSE, "Food and supplies"),
|
||||||
|
("Expenses:Maintenance", AccountType.EXPENSE, "Repairs and maintenance"),
|
||||||
|
("Expenses:Other", AccountType.EXPENSE, "Miscellaneous expenses"),
|
||||||
|
]
|
||||||
62
crud.py
62
crud.py
|
|
@ -49,6 +49,7 @@ async def get_account(account_id: str) -> Optional[Account]:
|
||||||
|
|
||||||
|
|
||||||
async def get_account_by_name(name: str) -> Optional[Account]:
|
async def get_account_by_name(name: str) -> Optional[Account]:
|
||||||
|
"""Get account by name (hierarchical format)"""
|
||||||
return await db.fetchone(
|
return await db.fetchone(
|
||||||
"SELECT * FROM accounts WHERE name = :name",
|
"SELECT * FROM accounts WHERE name = :name",
|
||||||
{"name": name},
|
{"name": name},
|
||||||
|
|
@ -74,9 +75,22 @@ async def get_accounts_by_type(account_type: AccountType) -> list[Account]:
|
||||||
async def get_or_create_user_account(
|
async def get_or_create_user_account(
|
||||||
user_id: str, account_type: AccountType, base_name: str
|
user_id: str, account_type: AccountType, base_name: str
|
||||||
) -> Account:
|
) -> Account:
|
||||||
"""Get or create a user-specific account (e.g., 'Accounts Payable - User123')"""
|
"""
|
||||||
account_name = f"{base_name} - {user_id[:8]}"
|
Get or create a user-specific account with hierarchical naming.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
get_or_create_user_account("af983632", AccountType.ASSET, "Accounts Receivable")
|
||||||
|
→ "Assets:Receivable:User-af983632"
|
||||||
|
|
||||||
|
get_or_create_user_account("af983632", AccountType.LIABILITY, "Accounts Payable")
|
||||||
|
→ "Liabilities:Payable:User-af983632"
|
||||||
|
"""
|
||||||
|
from .account_utils import format_hierarchical_account_name
|
||||||
|
|
||||||
|
# Generate hierarchical account name
|
||||||
|
account_name = format_hierarchical_account_name(account_type, base_name, user_id)
|
||||||
|
|
||||||
|
# Try to find existing account with this hierarchical name
|
||||||
account = await db.fetchone(
|
account = await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM accounts
|
SELECT * FROM accounts
|
||||||
|
|
@ -87,6 +101,7 @@ async def get_or_create_user_account(
|
||||||
)
|
)
|
||||||
|
|
||||||
if not account:
|
if not account:
|
||||||
|
# Create new account with hierarchical name
|
||||||
account = await create_account(
|
account = await create_account(
|
||||||
CreateAccount(
|
CreateAccount(
|
||||||
name=account_name,
|
name=account_name,
|
||||||
|
|
@ -126,13 +141,15 @@ async def create_journal_entry(
|
||||||
created_at=datetime.now(),
|
created_at=datetime.now(),
|
||||||
reference=data.reference,
|
reference=data.reference,
|
||||||
lines=[],
|
lines=[],
|
||||||
|
flag=data.flag,
|
||||||
|
meta=data.meta,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Insert journal entry without the lines field (lines are stored in entry_lines table)
|
# Insert journal entry without the lines field (lines are stored in entry_lines table)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO journal_entries (id, description, entry_date, created_by, created_at, reference)
|
INSERT INTO journal_entries (id, description, entry_date, created_by, created_at, reference, flag, meta)
|
||||||
VALUES (:id, :description, :entry_date, :created_by, :created_at, :reference)
|
VALUES (:id, :description, :entry_date, :created_by, :created_at, :reference, :flag, :meta)
|
||||||
""",
|
""",
|
||||||
{
|
{
|
||||||
"id": journal_entry.id,
|
"id": journal_entry.id,
|
||||||
|
|
@ -141,6 +158,8 @@ async def create_journal_entry(
|
||||||
"created_by": journal_entry.created_by,
|
"created_by": journal_entry.created_by,
|
||||||
"created_at": journal_entry.created_at,
|
"created_at": journal_entry.created_at,
|
||||||
"reference": journal_entry.reference,
|
"reference": journal_entry.reference,
|
||||||
|
"flag": journal_entry.flag.value,
|
||||||
|
"meta": json.dumps(journal_entry.meta),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -268,6 +287,11 @@ async def get_journal_entries_by_user(
|
||||||
|
|
||||||
entries = []
|
entries = []
|
||||||
for entry_data in entries_data:
|
for entry_data in entries_data:
|
||||||
|
# Parse flag and meta from database
|
||||||
|
from .models import JournalEntryFlag
|
||||||
|
flag = JournalEntryFlag(entry_data.get("flag", "*"))
|
||||||
|
meta = json.loads(entry_data.get("meta", "{}")) if entry_data.get("meta") else {}
|
||||||
|
|
||||||
entry = JournalEntry(
|
entry = JournalEntry(
|
||||||
id=entry_data["id"],
|
id=entry_data["id"],
|
||||||
description=entry_data["description"],
|
description=entry_data["description"],
|
||||||
|
|
@ -275,6 +299,8 @@ async def get_journal_entries_by_user(
|
||||||
created_by=entry_data["created_by"],
|
created_by=entry_data["created_by"],
|
||||||
created_at=entry_data["created_at"],
|
created_at=entry_data["created_at"],
|
||||||
reference=entry_data["reference"],
|
reference=entry_data["reference"],
|
||||||
|
flag=flag,
|
||||||
|
meta=meta,
|
||||||
lines=[],
|
lines=[],
|
||||||
)
|
)
|
||||||
entry.lines = await get_entry_lines(entry.id)
|
entry.lines = await get_entry_lines(entry.id)
|
||||||
|
|
@ -346,23 +372,27 @@ async def get_user_balance(user_id: str) -> UserBalance:
|
||||||
fiat_amount = metadata.get("fiat_amount")
|
fiat_amount = metadata.get("fiat_amount")
|
||||||
|
|
||||||
if fiat_currency and fiat_amount:
|
if fiat_currency and fiat_amount:
|
||||||
|
from decimal import Decimal
|
||||||
# Initialize currency if not exists
|
# Initialize currency if not exists
|
||||||
if fiat_currency not in fiat_balances:
|
if fiat_currency not in fiat_balances:
|
||||||
fiat_balances[fiat_currency] = 0.0
|
fiat_balances[fiat_currency] = Decimal("0")
|
||||||
|
|
||||||
|
# Convert fiat_amount to Decimal
|
||||||
|
fiat_decimal = Decimal(str(fiat_amount))
|
||||||
|
|
||||||
# Calculate fiat balance based on account type
|
# Calculate fiat balance based on account type
|
||||||
if account.account_type == AccountType.LIABILITY:
|
if account.account_type == AccountType.LIABILITY:
|
||||||
# Liability: credit increases (castle owes more), debit decreases
|
# Liability: credit increases (castle owes more), debit decreases
|
||||||
if line["credit"] > 0:
|
if line["credit"] > 0:
|
||||||
fiat_balances[fiat_currency] += fiat_amount
|
fiat_balances[fiat_currency] += fiat_decimal
|
||||||
elif line["debit"] > 0:
|
elif line["debit"] > 0:
|
||||||
fiat_balances[fiat_currency] -= fiat_amount
|
fiat_balances[fiat_currency] -= fiat_decimal
|
||||||
elif account.account_type == AccountType.ASSET:
|
elif account.account_type == AccountType.ASSET:
|
||||||
# Asset (receivable): debit increases (user owes more), credit decreases
|
# Asset (receivable): debit increases (user owes more), credit decreases
|
||||||
if line["debit"] > 0:
|
if line["debit"] > 0:
|
||||||
fiat_balances[fiat_currency] -= fiat_amount
|
fiat_balances[fiat_currency] -= fiat_decimal
|
||||||
elif line["credit"] > 0:
|
elif line["credit"] > 0:
|
||||||
fiat_balances[fiat_currency] += fiat_amount
|
fiat_balances[fiat_currency] += fiat_decimal
|
||||||
|
|
||||||
# Calculate satoshi balance
|
# Calculate satoshi balance
|
||||||
# If it's a liability account (castle owes user), it's positive
|
# If it's a liability account (castle owes user), it's positive
|
||||||
|
|
@ -419,23 +449,27 @@ async def get_all_user_balances() -> list[UserBalance]:
|
||||||
fiat_amount = metadata.get("fiat_amount")
|
fiat_amount = metadata.get("fiat_amount")
|
||||||
|
|
||||||
if fiat_currency and fiat_amount:
|
if fiat_currency and fiat_amount:
|
||||||
|
from decimal import Decimal
|
||||||
# Initialize currency if not exists
|
# Initialize currency if not exists
|
||||||
if fiat_currency not in fiat_balances:
|
if fiat_currency not in fiat_balances:
|
||||||
fiat_balances[fiat_currency] = 0.0
|
fiat_balances[fiat_currency] = Decimal("0")
|
||||||
|
|
||||||
|
# Convert fiat_amount to Decimal
|
||||||
|
fiat_decimal = Decimal(str(fiat_amount))
|
||||||
|
|
||||||
# Calculate fiat balance based on account type
|
# Calculate fiat balance based on account type
|
||||||
if account.account_type == AccountType.LIABILITY:
|
if account.account_type == AccountType.LIABILITY:
|
||||||
# Liability: credit increases (castle owes more), debit decreases
|
# Liability: credit increases (castle owes more), debit decreases
|
||||||
if line["credit"] > 0:
|
if line["credit"] > 0:
|
||||||
fiat_balances[fiat_currency] += fiat_amount
|
fiat_balances[fiat_currency] += fiat_decimal
|
||||||
elif line["debit"] > 0:
|
elif line["debit"] > 0:
|
||||||
fiat_balances[fiat_currency] -= fiat_amount
|
fiat_balances[fiat_currency] -= fiat_decimal
|
||||||
elif account.account_type == AccountType.ASSET:
|
elif account.account_type == AccountType.ASSET:
|
||||||
# Asset (receivable): debit increases (user owes more), credit decreases
|
# Asset (receivable): debit increases (user owes more), credit decreases
|
||||||
if line["debit"] > 0:
|
if line["debit"] > 0:
|
||||||
fiat_balances[fiat_currency] -= fiat_amount
|
fiat_balances[fiat_currency] -= fiat_decimal
|
||||||
elif line["credit"] > 0:
|
elif line["credit"] > 0:
|
||||||
fiat_balances[fiat_currency] += fiat_amount
|
fiat_balances[fiat_currency] += fiat_decimal
|
||||||
|
|
||||||
# Calculate satoshi balance
|
# Calculate satoshi balance
|
||||||
if account.account_type == AccountType.LIABILITY:
|
if account.account_type == AccountType.LIABILITY:
|
||||||
|
|
|
||||||
|
|
@ -177,3 +177,96 @@ async def m004_manual_payment_requests(db):
|
||||||
CREATE INDEX idx_manual_payment_requests_status ON manual_payment_requests (status);
|
CREATE INDEX idx_manual_payment_requests_status ON manual_payment_requests (status);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m005_add_flag_and_meta(db):
|
||||||
|
"""
|
||||||
|
Add flag and meta columns to journal_entries table.
|
||||||
|
- flag: Transaction status (* = cleared, ! = pending, # = flagged, x = void)
|
||||||
|
- meta: JSON metadata for audit trail (source, tags, links, notes)
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE journal_entries ADD COLUMN flag TEXT DEFAULT '*';
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE journal_entries ADD COLUMN meta TEXT DEFAULT '{}';
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m006_hierarchical_account_names(db):
|
||||||
|
"""
|
||||||
|
Migrate account names to hierarchical Beancount-style format.
|
||||||
|
- "Cash" → "Assets:Cash"
|
||||||
|
- "Accounts Receivable" → "Assets:Receivable"
|
||||||
|
- "Food & Supplies" → "Expenses:Food:Supplies"
|
||||||
|
- "Accounts Receivable - af983632" → "Assets:Receivable:User-af983632"
|
||||||
|
"""
|
||||||
|
from .account_utils import migrate_account_name
|
||||||
|
from .models import AccountType
|
||||||
|
|
||||||
|
# Get all existing accounts
|
||||||
|
accounts = await db.fetchall("SELECT * FROM accounts")
|
||||||
|
|
||||||
|
# Mapping of old names to new names
|
||||||
|
name_mappings = {
|
||||||
|
# Assets
|
||||||
|
"cash": "Assets:Cash",
|
||||||
|
"bank": "Assets:Bank",
|
||||||
|
"lightning": "Assets:Lightning:Balance",
|
||||||
|
"accounts_receivable": "Assets:Receivable",
|
||||||
|
|
||||||
|
# Liabilities
|
||||||
|
"accounts_payable": "Liabilities:Payable",
|
||||||
|
|
||||||
|
# Equity
|
||||||
|
"member_equity": "Equity:MemberEquity",
|
||||||
|
"retained_earnings": "Equity:RetainedEarnings",
|
||||||
|
|
||||||
|
# Revenue → Income
|
||||||
|
"accommodation_revenue": "Income:Accommodation",
|
||||||
|
"service_revenue": "Income:Service",
|
||||||
|
"other_revenue": "Income:Other",
|
||||||
|
|
||||||
|
# Expenses
|
||||||
|
"utilities": "Expenses:Utilities",
|
||||||
|
"food": "Expenses:Food:Supplies",
|
||||||
|
"maintenance": "Expenses:Maintenance",
|
||||||
|
"other_expense": "Expenses:Other",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update default accounts using ID-based mapping
|
||||||
|
for old_id, new_name in name_mappings.items():
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE accounts
|
||||||
|
SET name = :new_name
|
||||||
|
WHERE id = :old_id
|
||||||
|
""",
|
||||||
|
{"new_name": new_name, "old_id": old_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update user-specific accounts (those with user_id set)
|
||||||
|
user_accounts = await db.fetchall(
|
||||||
|
"SELECT * FROM accounts WHERE user_id IS NOT NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
for account in user_accounts:
|
||||||
|
# Parse account type
|
||||||
|
account_type = AccountType(account["account_type"])
|
||||||
|
|
||||||
|
# Migrate name
|
||||||
|
new_name = migrate_account_name(account["name"], account_type)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE accounts
|
||||||
|
SET name = :new_name
|
||||||
|
WHERE id = :id
|
||||||
|
""",
|
||||||
|
{"new_name": new_name, "id": account["id"]}
|
||||||
|
)
|
||||||
|
|
|
||||||
21
models.py
21
models.py
|
|
@ -1,4 +1,5 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -13,6 +14,14 @@ class AccountType(str, Enum):
|
||||||
EXPENSE = "expense"
|
EXPENSE = "expense"
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryFlag(str, Enum):
|
||||||
|
"""Transaction status flags (Beancount-style)"""
|
||||||
|
CLEARED = "*" # Fully reconciled/confirmed
|
||||||
|
PENDING = "!" # Not yet confirmed/awaiting approval
|
||||||
|
FLAGGED = "#" # Needs review/attention
|
||||||
|
VOID = "x" # Voided/cancelled entry
|
||||||
|
|
||||||
|
|
||||||
class Account(BaseModel):
|
class Account(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
|
|
@ -55,6 +64,8 @@ class JournalEntry(BaseModel):
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
reference: Optional[str] = None # Invoice ID or reference number
|
reference: Optional[str] = None # Invoice ID or reference number
|
||||||
lines: list[EntryLine] = []
|
lines: list[EntryLine] = []
|
||||||
|
flag: JournalEntryFlag = JournalEntryFlag.CLEARED # Transaction status
|
||||||
|
meta: dict = {} # Metadata: source, tags, links, notes, etc.
|
||||||
|
|
||||||
|
|
||||||
class CreateJournalEntry(BaseModel):
|
class CreateJournalEntry(BaseModel):
|
||||||
|
|
@ -62,20 +73,22 @@ class CreateJournalEntry(BaseModel):
|
||||||
entry_date: Optional[datetime] = None
|
entry_date: Optional[datetime] = None
|
||||||
reference: Optional[str] = None
|
reference: Optional[str] = None
|
||||||
lines: list[CreateEntryLine]
|
lines: list[CreateEntryLine]
|
||||||
|
flag: JournalEntryFlag = JournalEntryFlag.CLEARED
|
||||||
|
meta: dict = {}
|
||||||
|
|
||||||
|
|
||||||
class UserBalance(BaseModel):
|
class UserBalance(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
balance: int # positive = castle owes user, negative = user owes castle
|
balance: int # positive = castle owes user, negative = user owes castle
|
||||||
accounts: list[Account] = []
|
accounts: list[Account] = []
|
||||||
fiat_balances: dict[str, float] = {} # e.g. {"EUR": 250.0, "USD": 100.0}
|
fiat_balances: dict[str, Decimal] = {} # e.g. {"EUR": Decimal("250.0"), "USD": Decimal("100.0")}
|
||||||
|
|
||||||
|
|
||||||
class ExpenseEntry(BaseModel):
|
class ExpenseEntry(BaseModel):
|
||||||
"""Helper model for creating expense entries"""
|
"""Helper model for creating expense entries"""
|
||||||
|
|
||||||
description: str
|
description: str
|
||||||
amount: float # Amount in the specified currency (or satoshis if currency is None)
|
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
||||||
expense_account: str # account name or ID
|
expense_account: str # account name or ID
|
||||||
is_equity: bool = False # True = equity contribution, False = liability (castle owes user)
|
is_equity: bool = False # True = equity contribution, False = liability (castle owes user)
|
||||||
user_wallet: str
|
user_wallet: str
|
||||||
|
|
@ -87,7 +100,7 @@ class ReceivableEntry(BaseModel):
|
||||||
"""Helper model for creating accounts receivable entries"""
|
"""Helper model for creating accounts receivable entries"""
|
||||||
|
|
||||||
description: str
|
description: str
|
||||||
amount: float # Amount in the specified currency (or satoshis if currency is None)
|
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
||||||
revenue_account: str # account name or ID
|
revenue_account: str # account name or ID
|
||||||
user_id: str # The user_id (not wallet_id) of the user who owes the castle
|
user_id: str # The user_id (not wallet_id) of the user who owes the castle
|
||||||
reference: Optional[str] = None
|
reference: Optional[str] = None
|
||||||
|
|
@ -98,7 +111,7 @@ class RevenueEntry(BaseModel):
|
||||||
"""Helper model for creating revenue entries"""
|
"""Helper model for creating revenue entries"""
|
||||||
|
|
||||||
description: str
|
description: str
|
||||||
amount: float # Amount in the specified currency (or satoshis if currency is None)
|
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
||||||
revenue_account: str
|
revenue_account: str
|
||||||
payment_method_account: str # e.g., "Cash", "Bank", "Lightning"
|
payment_method_account: str # e.g., "Cash", "Bank", "Lightning"
|
||||||
reference: Optional[str] = None
|
reference: Optional[str] = None
|
||||||
|
|
|
||||||
|
|
@ -640,7 +640,7 @@ window.app = Vue.createApp({
|
||||||
if (line.debit > 0) {
|
if (line.debit > 0) {
|
||||||
// Check if the account is associated with this user's receivables
|
// Check if the account is associated with this user's receivables
|
||||||
const account = this.accounts.find(a => a.id === line.account_id)
|
const account = this.accounts.find(a => a.id === line.account_id)
|
||||||
if (account && account.name && account.name.includes('Accounts Receivable') && account.account_type === 'asset') {
|
if (account && account.name && account.name.includes('Assets:Receivable') && account.account_type === 'asset') {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -657,7 +657,7 @@ window.app = Vue.createApp({
|
||||||
if (line.credit > 0) {
|
if (line.credit > 0) {
|
||||||
// Check if the account is associated with this user's payables
|
// Check if the account is associated with this user's payables
|
||||||
const account = this.accounts.find(a => a.id === line.account_id)
|
const account = this.accounts.find(a => a.id === line.account_id)
|
||||||
if (account && account.name && account.name.includes('Accounts Payable') && account.account_type === 'liability') {
|
if (account && account.name && account.name.includes('Liabilities:Payable') && account.account_type === 'liability') {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
views_api.py
55
views_api.py
|
|
@ -1,3 +1,4 @@
|
||||||
|
from decimal import Decimal
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
@ -43,6 +44,7 @@ from .models import (
|
||||||
ExpenseEntry,
|
ExpenseEntry,
|
||||||
GeneratePaymentInvoice,
|
GeneratePaymentInvoice,
|
||||||
JournalEntry,
|
JournalEntry,
|
||||||
|
JournalEntryFlag,
|
||||||
ManualPaymentRequest,
|
ManualPaymentRequest,
|
||||||
ReceivableEntry,
|
ReceivableEntry,
|
||||||
RecordPayment,
|
RecordPayment,
|
||||||
|
|
@ -231,14 +233,14 @@ async def api_create_expense_entry(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert fiat to satoshis
|
# Convert fiat to satoshis
|
||||||
amount_sats = await fiat_amount_as_satoshis(data.amount, data.currency)
|
amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency)
|
||||||
|
|
||||||
# Store currency metadata
|
# Store currency metadata (store fiat_amount as string to preserve Decimal precision)
|
||||||
metadata = {
|
metadata = {
|
||||||
"fiat_currency": data.currency.upper(),
|
"fiat_currency": data.currency.upper(),
|
||||||
"fiat_amount": round(data.amount, ndigits=3),
|
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places
|
||||||
"fiat_rate": amount_sats / data.amount if data.amount > 0 else 0,
|
"fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0,
|
||||||
"btc_rate": (data.amount / amount_sats * 100_000_000) if amount_sats > 0 else 0,
|
"btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get or create expense account
|
# Get or create expense account
|
||||||
|
|
@ -267,9 +269,19 @@ async def api_create_expense_entry(
|
||||||
# Create journal entry
|
# Create journal entry
|
||||||
# DR Expense, CR User Account (Liability or Equity)
|
# DR Expense, CR User Account (Liability or Equity)
|
||||||
description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else ""
|
description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else ""
|
||||||
|
|
||||||
|
# Add meta information for audit trail
|
||||||
|
entry_meta = {
|
||||||
|
"source": "api",
|
||||||
|
"created_via": "expense_entry",
|
||||||
|
"user_id": wallet.wallet.user,
|
||||||
|
"is_equity": data.is_equity,
|
||||||
|
}
|
||||||
|
|
||||||
entry_data = CreateJournalEntry(
|
entry_data = CreateJournalEntry(
|
||||||
description=data.description + description_suffix,
|
description=data.description + description_suffix,
|
||||||
reference=data.reference,
|
reference=data.reference,
|
||||||
|
meta=entry_meta,
|
||||||
lines=[
|
lines=[
|
||||||
CreateEntryLine(
|
CreateEntryLine(
|
||||||
account_id=expense_account.id,
|
account_id=expense_account.id,
|
||||||
|
|
@ -315,14 +327,14 @@ async def api_create_receivable_entry(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert fiat to satoshis
|
# Convert fiat to satoshis
|
||||||
amount_sats = await fiat_amount_as_satoshis(data.amount, data.currency)
|
amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency)
|
||||||
|
|
||||||
# Store currency metadata
|
# Store currency metadata (store fiat_amount as string to preserve Decimal precision)
|
||||||
metadata = {
|
metadata = {
|
||||||
"fiat_currency": data.currency.upper(),
|
"fiat_currency": data.currency.upper(),
|
||||||
"fiat_amount": round(data.amount, ndigits=3),
|
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places
|
||||||
"fiat_rate": amount_sats / data.amount if data.amount > 0 else 0,
|
"fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0,
|
||||||
"btc_rate": (data.amount / amount_sats * 100_000_000) if amount_sats > 0 else 0,
|
"btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get or create revenue account
|
# Get or create revenue account
|
||||||
|
|
@ -343,9 +355,19 @@ async def api_create_receivable_entry(
|
||||||
# Create journal entry
|
# Create journal entry
|
||||||
# DR Accounts Receivable (User), CR Revenue
|
# DR Accounts Receivable (User), CR Revenue
|
||||||
description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else ""
|
description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else ""
|
||||||
|
|
||||||
|
# Add meta information for audit trail
|
||||||
|
entry_meta = {
|
||||||
|
"source": "api",
|
||||||
|
"created_via": "receivable_entry",
|
||||||
|
"debtor_user_id": data.user_id,
|
||||||
|
}
|
||||||
|
|
||||||
entry_data = CreateJournalEntry(
|
entry_data = CreateJournalEntry(
|
||||||
description=data.description + description_suffix,
|
description=data.description + description_suffix,
|
||||||
reference=data.reference,
|
reference=data.reference,
|
||||||
|
flag=JournalEntryFlag.PENDING, # Receivables start as pending until paid
|
||||||
|
meta=entry_meta,
|
||||||
lines=[
|
lines=[
|
||||||
CreateEntryLine(
|
CreateEntryLine(
|
||||||
account_id=user_receivable.id,
|
account_id=user_receivable.id,
|
||||||
|
|
@ -447,7 +469,7 @@ async def api_get_my_balance(
|
||||||
for user_balance in all_balances:
|
for user_balance in all_balances:
|
||||||
for currency, amount in user_balance.fiat_balances.items():
|
for currency, amount in user_balance.fiat_balances.items():
|
||||||
if currency not in total_fiat_balances:
|
if currency not in total_fiat_balances:
|
||||||
total_fiat_balances[currency] = 0.0
|
total_fiat_balances[currency] = Decimal("0")
|
||||||
# Add all balances (positive and negative)
|
# Add all balances (positive and negative)
|
||||||
total_fiat_balances[currency] += amount
|
total_fiat_balances[currency] += amount
|
||||||
|
|
||||||
|
|
@ -579,9 +601,20 @@ async def api_record_payment(
|
||||||
# Create journal entry to record payment
|
# Create journal entry to record payment
|
||||||
# DR Lightning Balance, CR Accounts Receivable (User)
|
# DR Lightning Balance, CR Accounts Receivable (User)
|
||||||
# This reduces what the user owes
|
# This reduces what the user owes
|
||||||
|
|
||||||
|
# Add meta information for audit trail
|
||||||
|
entry_meta = {
|
||||||
|
"source": "lightning_payment",
|
||||||
|
"created_via": "record_payment",
|
||||||
|
"payment_hash": data.payment_hash,
|
||||||
|
"payer_user_id": wallet.wallet.user,
|
||||||
|
}
|
||||||
|
|
||||||
entry_data = CreateJournalEntry(
|
entry_data = CreateJournalEntry(
|
||||||
description=f"Lightning payment from user {wallet.wallet.user[:8]}",
|
description=f"Lightning payment from user {wallet.wallet.user[:8]}",
|
||||||
reference=data.payment_hash,
|
reference=data.payment_hash,
|
||||||
|
flag=JournalEntryFlag.CLEARED, # Payment is immediately cleared
|
||||||
|
meta=entry_meta,
|
||||||
lines=[
|
lines=[
|
||||||
CreateEntryLine(
|
CreateEntryLine(
|
||||||
account_id=lightning_account.id,
|
account_id=lightning_account.id,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue