castle/migrations.py
padreug 1a28ec59eb 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.
2025-10-23 00:17:04 +02:00

272 lines
7.8 KiB
Python

async def m001_initial(db):
"""
Initial migration for Castle accounting extension.
Creates tables for double-entry bookkeeping system.
"""
await db.execute(
f"""
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
account_type TEXT NOT NULL,
description TEXT,
user_id TEXT,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
await db.execute(
"""
CREATE INDEX idx_accounts_user_id ON accounts (user_id);
"""
)
await db.execute(
"""
CREATE INDEX idx_accounts_type ON accounts (account_type);
"""
)
await db.execute(
f"""
CREATE TABLE journal_entries (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
entry_date TIMESTAMP NOT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
reference TEXT
);
"""
)
await db.execute(
"""
CREATE INDEX idx_journal_entries_created_by ON journal_entries (created_by);
"""
)
await db.execute(
"""
CREATE INDEX idx_journal_entries_date ON journal_entries (entry_date);
"""
)
await db.execute(
f"""
CREATE TABLE entry_lines (
id TEXT PRIMARY KEY,
journal_entry_id TEXT NOT NULL,
account_id TEXT NOT NULL,
debit INTEGER NOT NULL DEFAULT 0,
credit INTEGER NOT NULL DEFAULT 0,
description TEXT,
metadata TEXT DEFAULT '{{}}'
);
"""
)
await db.execute(
"""
CREATE INDEX idx_entry_lines_journal_entry ON entry_lines (journal_entry_id);
"""
)
await db.execute(
"""
CREATE INDEX idx_entry_lines_account ON entry_lines (account_id);
"""
)
# Insert default chart of accounts
default_accounts = [
# Assets
("cash", "Cash", "asset", "Cash on hand"),
("bank", "Bank Account", "asset", "Bank account"),
("lightning", "Lightning Balance", "asset", "Lightning Network balance"),
("accounts_receivable", "Accounts Receivable", "asset", "Money owed to the Castle"),
# Liabilities
("accounts_payable", "Accounts Payable", "liability", "Money owed by the Castle"),
# Equity
("member_equity", "Member Equity", "equity", "Member contributions"),
("retained_earnings", "Retained Earnings", "equity", "Accumulated profits"),
# Revenue
("accommodation_revenue", "Accommodation Revenue", "revenue", "Revenue from stays"),
("service_revenue", "Service Revenue", "revenue", "Revenue from services"),
("other_revenue", "Other Revenue", "revenue", "Other revenue"),
# Expenses
("utilities", "Utilities", "expense", "Electricity, water, internet"),
("food", "Food & Supplies", "expense", "Food and supplies"),
("maintenance", "Maintenance", "expense", "Repairs and maintenance"),
("other_expense", "Other Expenses", "expense", "Miscellaneous expenses"),
]
for acc_id, name, acc_type, desc in default_accounts:
await db.execute(
"""
INSERT INTO accounts (id, name, account_type, description)
VALUES (:id, :name, :type, :description)
""",
{"id": acc_id, "name": name, "type": acc_type, "description": desc}
)
async def m002_extension_settings(db):
"""
Create extension_settings table for Castle configuration.
"""
await db.execute(
f"""
CREATE TABLE extension_settings (
id TEXT NOT NULL PRIMARY KEY,
castle_wallet_id TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
async def m003_user_wallet_settings(db):
"""
Create user_wallet_settings table for per-user wallet configuration.
"""
await db.execute(
f"""
CREATE TABLE user_wallet_settings (
id TEXT NOT NULL PRIMARY KEY,
user_wallet_id TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
async def m004_manual_payment_requests(db):
"""
Create manual_payment_requests table for user payment requests to Castle.
"""
await db.execute(
f"""
CREATE TABLE manual_payment_requests (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
amount INTEGER NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
reviewed_at TIMESTAMP,
reviewed_by TEXT,
journal_entry_id TEXT
);
"""
)
await db.execute(
"""
CREATE INDEX idx_manual_payment_requests_user_id ON manual_payment_requests (user_id);
"""
)
await db.execute(
"""
CREATE INDEX idx_manual_payment_requests_status ON manual_payment_requests (status);
"""
)
async def m005_add_flag_and_meta(db):
"""
Add flag and meta columns to journal_entries table.
- flag: Transaction status (* = cleared, ! = pending, # = flagged, x = void)
- meta: JSON metadata for audit trail (source, tags, links, notes)
"""
await db.execute(
"""
ALTER TABLE journal_entries ADD COLUMN flag TEXT DEFAULT '*';
"""
)
await db.execute(
"""
ALTER TABLE journal_entries ADD COLUMN meta TEXT DEFAULT '{}';
"""
)
async def m006_hierarchical_account_names(db):
"""
Migrate account names to hierarchical Beancount-style format.
- "Cash""Assets:Cash"
- "Accounts Receivable""Assets:Receivable"
- "Food & Supplies""Expenses:Food:Supplies"
- "Accounts Receivable - af983632""Assets:Receivable:User-af983632"
"""
from .account_utils import migrate_account_name
from .models import AccountType
# Get all existing accounts
accounts = await db.fetchall("SELECT * FROM accounts")
# Mapping of old names to new names
name_mappings = {
# Assets
"cash": "Assets:Cash",
"bank": "Assets:Bank",
"lightning": "Assets: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"]}
)