Adds support for on-chain Bitcoin payments by: - Introducing a new `Assets:Bitcoin:OnChain` account. - Updating the `SettleReceivable` and `PayUser` models to include `txid` for storing transaction IDs. - Modifying the API endpoints to handle `btc_onchain` as a valid payment method and associate it with the new account. This allows tracking on-chain Bitcoin transactions separately from Lightning Network payments.
365 lines
10 KiB
Python
365 lines
10 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:Bitcoin:Lightning",
|
|
"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"]}
|
|
)
|
|
|
|
|
|
async def m007_balance_assertions(db):
|
|
"""
|
|
Create balance_assertions table for reconciliation.
|
|
Allows admins to assert expected balances at specific dates.
|
|
"""
|
|
await db.execute(
|
|
f"""
|
|
CREATE TABLE balance_assertions (
|
|
id TEXT PRIMARY KEY,
|
|
date TIMESTAMP NOT NULL,
|
|
account_id TEXT NOT NULL,
|
|
expected_balance_sats INTEGER NOT NULL,
|
|
expected_balance_fiat TEXT,
|
|
fiat_currency TEXT,
|
|
tolerance_sats INTEGER DEFAULT 0,
|
|
tolerance_fiat TEXT DEFAULT '0',
|
|
checked_balance_sats INTEGER,
|
|
checked_balance_fiat TEXT,
|
|
difference_sats INTEGER,
|
|
difference_fiat TEXT,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
created_by TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
|
checked_at TIMESTAMP,
|
|
FOREIGN KEY (account_id) REFERENCES accounts (id)
|
|
);
|
|
"""
|
|
)
|
|
|
|
await db.execute(
|
|
"""
|
|
CREATE INDEX idx_balance_assertions_account_id ON balance_assertions (account_id);
|
|
"""
|
|
)
|
|
|
|
await db.execute(
|
|
"""
|
|
CREATE INDEX idx_balance_assertions_status ON balance_assertions (status);
|
|
"""
|
|
)
|
|
|
|
await db.execute(
|
|
"""
|
|
CREATE INDEX idx_balance_assertions_date ON balance_assertions (date);
|
|
"""
|
|
)
|
|
|
|
|
|
async def m008_rename_lightning_account(db):
|
|
"""
|
|
Rename Lightning account from Assets:Lightning:Balance to Assets:Bitcoin:Lightning
|
|
for better naming consistency.
|
|
"""
|
|
await db.execute(
|
|
"""
|
|
UPDATE accounts
|
|
SET name = 'Assets:Bitcoin:Lightning'
|
|
WHERE name = 'Assets:Lightning:Balance'
|
|
"""
|
|
)
|
|
|
|
|
|
async def m009_add_onchain_bitcoin_account(db):
|
|
"""
|
|
Add Assets:Bitcoin:OnChain account for on-chain Bitcoin transactions.
|
|
This allows tracking on-chain Bitcoin separately from Lightning Network payments.
|
|
"""
|
|
import uuid
|
|
|
|
# Check if the account already exists
|
|
existing = await db.fetchone(
|
|
"""
|
|
SELECT id FROM accounts
|
|
WHERE name = 'Assets:Bitcoin:OnChain'
|
|
"""
|
|
)
|
|
|
|
if not existing:
|
|
# Create the on-chain Bitcoin asset account
|
|
await db.execute(
|
|
f"""
|
|
INSERT INTO accounts (id, name, account_type, description, created_at)
|
|
VALUES (:id, :name, :type, :description, {db.timestamp_now})
|
|
""",
|
|
{
|
|
"id": str(uuid.uuid4()),
|
|
"name": "Assets:Bitcoin:OnChain",
|
|
"type": "asset",
|
|
"description": "On-chain Bitcoin wallet"
|
|
}
|
|
)
|