Removes redundant metadata from entries and postings. The cost syntax already contains fiat/exchange rate information. Metadata such as 'created-via', 'is-equity', and payer/payee can be inferred from transaction direction, tags, and account names.
529 lines
16 KiB
Python
529 lines
16 KiB
Python
"""
|
|
Format Castle entries as Beancount transactions for Fava API.
|
|
|
|
All entries submitted to Fava must follow Beancount syntax.
|
|
This module converts Castle data models to Fava API format.
|
|
|
|
Key concepts:
|
|
- Amounts are strings: "200000 SATS" or "100.00 EUR"
|
|
- Cost basis syntax: "200000 SATS {100.00 EUR}"
|
|
- Flags: "*" (cleared), "!" (pending), "#" (flagged), "?" (unknown)
|
|
- Entry type: "t": "Transaction" (required by Fava)
|
|
"""
|
|
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
def format_transaction(
|
|
date_val: date,
|
|
flag: str,
|
|
narration: str,
|
|
postings: List[Dict[str, Any]],
|
|
payee: str = "",
|
|
tags: Optional[List[str]] = None,
|
|
links: Optional[List[str]] = None,
|
|
meta: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Format a transaction for Fava's add_entries API.
|
|
|
|
Args:
|
|
date_val: Transaction date
|
|
flag: Beancount flag (* = cleared, ! = pending, # = flagged)
|
|
narration: Description
|
|
postings: List of posting dicts (formatted by format_posting)
|
|
payee: Optional payee
|
|
tags: Optional tags (e.g., ["expense-entry", "approved"])
|
|
links: Optional links (e.g., ["castle-abc123", "^invoice-xyz"])
|
|
meta: Optional transaction metadata
|
|
|
|
Returns:
|
|
Fava API entry dict
|
|
|
|
Example:
|
|
entry = format_transaction(
|
|
date_val=date.today(),
|
|
flag="*",
|
|
narration="Grocery shopping",
|
|
postings=[
|
|
format_posting_with_cost(
|
|
account="Expenses:Food",
|
|
amount_sats=36930,
|
|
fiat_currency="EUR",
|
|
fiat_amount=Decimal("36.93")
|
|
),
|
|
format_posting_with_cost(
|
|
account="Liabilities:Payable:User-abc",
|
|
amount_sats=-36930,
|
|
fiat_currency="EUR",
|
|
fiat_amount=Decimal("36.93")
|
|
)
|
|
],
|
|
tags=["expense-entry"],
|
|
links=["castle-abc123"],
|
|
meta={"user-id": "abc123", "source": "castle-expense-entry"}
|
|
)
|
|
"""
|
|
return {
|
|
"t": "Transaction", # REQUIRED by Fava API
|
|
"date": str(date_val),
|
|
"flag": flag,
|
|
"payee": payee or "", # Empty string, not None
|
|
"narration": narration,
|
|
"tags": tags or [],
|
|
"links": links or [],
|
|
"postings": postings,
|
|
"meta": meta or {}
|
|
}
|
|
|
|
|
|
def format_posting_with_cost(
|
|
account: str,
|
|
amount_sats: int,
|
|
fiat_currency: Optional[str] = None,
|
|
fiat_amount: Optional[Decimal] = None,
|
|
metadata: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Format a posting with cost basis for Fava API.
|
|
|
|
This is the RECOMMENDED format for all Castle transactions.
|
|
Uses Beancount's cost basis syntax to preserve exchange rates.
|
|
|
|
Args:
|
|
account: Account name (e.g., "Expenses:Food:Groceries")
|
|
amount_sats: Amount in satoshis (signed: positive = debit, negative = credit)
|
|
fiat_currency: Fiat currency (EUR, USD, etc.)
|
|
fiat_amount: Fiat amount (Decimal, unsigned)
|
|
metadata: Optional posting metadata
|
|
|
|
Returns:
|
|
Fava API posting dict
|
|
|
|
Example:
|
|
posting = format_posting_with_cost(
|
|
account="Expenses:Food",
|
|
amount_sats=200000,
|
|
fiat_currency="EUR",
|
|
fiat_amount=Decimal("100.00")
|
|
)
|
|
# Returns: {
|
|
# "account": "Expenses:Food",
|
|
# "amount": "200000 SATS {100.00 EUR}",
|
|
# "meta": {}
|
|
# }
|
|
# Note: All fiat/exchange rate info is in the cost syntax "{100.00 EUR}"
|
|
"""
|
|
# Build amount string with cost basis
|
|
if fiat_currency and fiat_amount and fiat_amount > 0:
|
|
# Cost basis syntax: "200000 SATS {100.00 EUR}"
|
|
# Sign is on the sats amount, fiat amount in cost basis is always positive
|
|
amount_str = f"{amount_sats} SATS {{{abs(fiat_amount):.2f} {fiat_currency}}}"
|
|
else:
|
|
# No cost basis: "200000 SATS"
|
|
amount_str = f"{amount_sats} SATS"
|
|
|
|
# Build metadata (only include explicitly passed metadata, not redundant fiat info)
|
|
# The cost syntax "{69.00 EUR}" already contains all fiat/exchange rate information
|
|
posting_meta = metadata or {}
|
|
|
|
return {
|
|
"account": account,
|
|
"amount": amount_str,
|
|
"meta": posting_meta
|
|
}
|
|
|
|
|
|
def format_posting_simple(
|
|
account: str,
|
|
amount_sats: int,
|
|
metadata: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Format a simple posting (SATS only, no cost basis).
|
|
|
|
Use this for:
|
|
- Lightning payments (no fiat conversion)
|
|
- SATS-only transactions
|
|
- Internal transfers
|
|
|
|
Args:
|
|
account: Account name
|
|
amount_sats: Amount in satoshis (signed)
|
|
metadata: Optional posting metadata
|
|
|
|
Returns:
|
|
Fava API posting dict
|
|
|
|
Example:
|
|
posting = format_posting_simple(
|
|
account="Assets:Bitcoin:Lightning",
|
|
amount_sats=200000
|
|
)
|
|
# Returns: {
|
|
# "account": "Assets:Bitcoin:Lightning",
|
|
# "amount": "200000 SATS",
|
|
# "meta": {}
|
|
# }
|
|
"""
|
|
return {
|
|
"account": account,
|
|
"amount": f"{amount_sats} SATS",
|
|
"meta": metadata or {}
|
|
}
|
|
|
|
|
|
def format_expense_entry(
|
|
user_id: str,
|
|
expense_account: str,
|
|
user_account: str,
|
|
amount_sats: int,
|
|
description: str,
|
|
entry_date: date,
|
|
is_equity: bool = False,
|
|
fiat_currency: Optional[str] = None,
|
|
fiat_amount: Optional[Decimal] = None,
|
|
reference: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Format an expense entry for submission to Fava.
|
|
|
|
Creates a pending transaction (flag="!") that requires admin approval.
|
|
|
|
Args:
|
|
user_id: User ID
|
|
expense_account: Expense account name (e.g., "Expenses:Food:Groceries")
|
|
user_account: User's liability/equity account name
|
|
amount_sats: Amount in satoshis (unsigned, will be signed correctly)
|
|
description: Entry description
|
|
entry_date: Date of entry
|
|
is_equity: Whether this is an equity contribution
|
|
fiat_currency: Optional fiat currency (EUR, USD)
|
|
fiat_amount: Optional fiat amount (unsigned)
|
|
reference: Optional reference (invoice ID, etc.)
|
|
|
|
Returns:
|
|
Fava API entry dict
|
|
|
|
Example:
|
|
entry = format_expense_entry(
|
|
user_id="abc123",
|
|
expense_account="Expenses:Food:Groceries",
|
|
user_account="Liabilities:Payable:User-abc123",
|
|
amount_sats=200000,
|
|
description="Grocery shopping",
|
|
entry_date=date.today(),
|
|
fiat_currency="EUR",
|
|
fiat_amount=Decimal("100.00")
|
|
)
|
|
"""
|
|
# Ensure amounts are unsigned for cost basis
|
|
amount_sats_abs = abs(amount_sats)
|
|
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
|
|
|
|
# Build narration
|
|
narration = description
|
|
if fiat_currency and fiat_amount_abs:
|
|
narration += f" ({fiat_amount_abs:.2f} {fiat_currency})"
|
|
|
|
# Build postings with cost basis
|
|
postings = [
|
|
format_posting_with_cost(
|
|
account=expense_account,
|
|
amount_sats=amount_sats_abs, # Positive = debit (expense increase)
|
|
fiat_currency=fiat_currency,
|
|
fiat_amount=fiat_amount_abs
|
|
),
|
|
format_posting_with_cost(
|
|
account=user_account,
|
|
amount_sats=-amount_sats_abs, # Negative = credit (liability/equity increase)
|
|
fiat_currency=fiat_currency,
|
|
fiat_amount=fiat_amount_abs
|
|
)
|
|
]
|
|
|
|
# Build entry metadata
|
|
# Note: created-via is redundant with #expense-entry tag
|
|
# Note: is-equity is redundant with account name (Equity vs Liabilities:Payable) and tags
|
|
entry_meta = {
|
|
"user-id": user_id,
|
|
"source": "castle-api"
|
|
}
|
|
|
|
# Build links
|
|
links = []
|
|
if reference:
|
|
links.append(reference)
|
|
|
|
# Build tags
|
|
tags = ["expense-entry"]
|
|
if is_equity:
|
|
tags.append("equity-contribution")
|
|
|
|
return format_transaction(
|
|
date_val=entry_date,
|
|
flag="!", # Pending approval
|
|
narration=narration,
|
|
postings=postings,
|
|
tags=tags,
|
|
links=links,
|
|
meta=entry_meta
|
|
)
|
|
|
|
|
|
def format_receivable_entry(
|
|
user_id: str,
|
|
revenue_account: str,
|
|
receivable_account: str,
|
|
amount_sats: int,
|
|
description: str,
|
|
entry_date: date,
|
|
fiat_currency: Optional[str] = None,
|
|
fiat_amount: Optional[Decimal] = None,
|
|
reference: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Format a receivable entry (user owes castle).
|
|
|
|
Creates a pending transaction that starts as receivable.
|
|
|
|
Args:
|
|
user_id: User ID
|
|
revenue_account: Revenue account name
|
|
receivable_account: User's receivable account name (Assets:Receivable:User-{id})
|
|
amount_sats: Amount in satoshis (unsigned)
|
|
description: Entry description
|
|
entry_date: Date of entry
|
|
fiat_currency: Optional fiat currency
|
|
fiat_amount: Optional fiat amount (unsigned)
|
|
reference: Optional reference
|
|
|
|
Returns:
|
|
Fava API entry dict
|
|
"""
|
|
amount_sats_abs = abs(amount_sats)
|
|
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
|
|
|
|
narration = description
|
|
if fiat_currency and fiat_amount_abs:
|
|
narration += f" ({fiat_amount_abs:.2f} {fiat_currency})"
|
|
|
|
postings = [
|
|
format_posting_with_cost(
|
|
account=receivable_account,
|
|
amount_sats=amount_sats_abs, # Positive = debit (asset increase - user owes)
|
|
fiat_currency=fiat_currency,
|
|
fiat_amount=fiat_amount_abs
|
|
),
|
|
format_posting_with_cost(
|
|
account=revenue_account,
|
|
amount_sats=-amount_sats_abs, # Negative = credit (revenue increase)
|
|
fiat_currency=fiat_currency,
|
|
fiat_amount=fiat_amount_abs
|
|
)
|
|
]
|
|
|
|
# Note: created-via is redundant with #receivable-entry tag
|
|
# Note: debtor-user-id is the same as user-id for receivables (redundant)
|
|
entry_meta = {
|
|
"user-id": user_id,
|
|
"source": "castle-api"
|
|
}
|
|
|
|
links = []
|
|
if reference:
|
|
links.append(reference)
|
|
|
|
return format_transaction(
|
|
date_val=entry_date,
|
|
flag="!", # Pending until paid
|
|
narration=narration,
|
|
postings=postings,
|
|
tags=["receivable-entry"],
|
|
links=links,
|
|
meta=entry_meta
|
|
)
|
|
|
|
|
|
def format_payment_entry(
|
|
user_id: str,
|
|
payment_account: str,
|
|
payable_or_receivable_account: str,
|
|
amount_sats: int,
|
|
description: str,
|
|
entry_date: date,
|
|
is_payable: bool = True,
|
|
fiat_currency: Optional[str] = None,
|
|
fiat_amount: Optional[Decimal] = None,
|
|
payment_hash: Optional[str] = None,
|
|
reference: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Format a payment entry (Lightning payment recorded).
|
|
|
|
Creates a cleared transaction (flag="*") since payment already happened.
|
|
|
|
Args:
|
|
user_id: User ID
|
|
payment_account: Payment method account (e.g., "Assets:Bitcoin:Lightning")
|
|
payable_or_receivable_account: User's account being settled
|
|
amount_sats: Amount in satoshis (unsigned)
|
|
description: Payment description
|
|
entry_date: Date of payment
|
|
is_payable: True if castle paying user (payable), False if user paying castle (receivable)
|
|
fiat_currency: Optional fiat currency
|
|
fiat_amount: Optional fiat amount (unsigned)
|
|
payment_hash: Lightning payment hash
|
|
reference: Optional reference
|
|
|
|
Returns:
|
|
Fava API entry dict
|
|
"""
|
|
amount_sats_abs = abs(amount_sats)
|
|
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
|
|
|
|
if is_payable:
|
|
# Castle paying user: DR Payable, CR Lightning
|
|
postings = [
|
|
format_posting_with_cost(
|
|
account=payable_or_receivable_account,
|
|
amount_sats=amount_sats_abs, # Positive = debit (liability decrease)
|
|
fiat_currency=fiat_currency,
|
|
fiat_amount=fiat_amount_abs
|
|
),
|
|
format_posting_with_cost(
|
|
account=payment_account,
|
|
amount_sats=-amount_sats_abs, # Negative = credit (asset decrease)
|
|
fiat_currency=fiat_currency,
|
|
fiat_amount=fiat_amount_abs,
|
|
metadata={"payment-hash": payment_hash} if payment_hash else None
|
|
)
|
|
]
|
|
else:
|
|
# User paying castle: DR Lightning, CR Receivable
|
|
postings = [
|
|
format_posting_with_cost(
|
|
account=payment_account,
|
|
amount_sats=amount_sats_abs, # Positive = debit (asset increase)
|
|
fiat_currency=fiat_currency,
|
|
fiat_amount=fiat_amount_abs,
|
|
metadata={"payment-hash": payment_hash} if payment_hash else None
|
|
),
|
|
format_posting_with_cost(
|
|
account=payable_or_receivable_account,
|
|
amount_sats=-amount_sats_abs, # Negative = credit (asset decrease)
|
|
fiat_currency=fiat_currency,
|
|
fiat_amount=fiat_amount_abs
|
|
)
|
|
]
|
|
|
|
# Note: created-via is redundant with #lightning-payment tag
|
|
# Note: payer/payee can be inferred from transaction direction and accounts
|
|
entry_meta = {
|
|
"user-id": user_id,
|
|
"source": "lightning_payment"
|
|
}
|
|
|
|
if payment_hash:
|
|
entry_meta["payment-hash"] = payment_hash
|
|
|
|
links = []
|
|
if reference:
|
|
links.append(reference)
|
|
if payment_hash:
|
|
links.append(f"ln-{payment_hash[:16]}")
|
|
|
|
return format_transaction(
|
|
date_val=entry_date,
|
|
flag="*", # Cleared (payment already happened)
|
|
narration=description,
|
|
postings=postings,
|
|
tags=["lightning-payment"],
|
|
links=links,
|
|
meta=entry_meta
|
|
)
|
|
|
|
|
|
def format_revenue_entry(
|
|
payment_account: str,
|
|
revenue_account: str,
|
|
amount_sats: int,
|
|
description: str,
|
|
entry_date: date,
|
|
fiat_currency: Optional[str] = None,
|
|
fiat_amount: Optional[Decimal] = None,
|
|
reference: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Format a revenue entry (castle receives payment directly).
|
|
|
|
Creates a cleared transaction (flag="*") since payment was received.
|
|
|
|
Example: Cash sale, Lightning payment received, bank transfer received.
|
|
|
|
Args:
|
|
payment_account: Payment method account (e.g., "Assets:Bitcoin:Lightning", "Assets:Cash")
|
|
revenue_account: Revenue account name (e.g., "Income:Sales", "Income:Services")
|
|
amount_sats: Amount in satoshis (unsigned)
|
|
description: Entry description
|
|
entry_date: Date of payment
|
|
fiat_currency: Optional fiat currency
|
|
fiat_amount: Optional fiat amount (unsigned)
|
|
reference: Optional reference
|
|
|
|
Returns:
|
|
Fava API entry dict
|
|
|
|
Example:
|
|
entry = format_revenue_entry(
|
|
payment_account="Assets:Cash",
|
|
revenue_account="Income:Sales",
|
|
amount_sats=100000,
|
|
description="Product sale",
|
|
entry_date=date.today(),
|
|
fiat_currency="EUR",
|
|
fiat_amount=Decimal("50.00")
|
|
)
|
|
"""
|
|
amount_sats_abs = abs(amount_sats)
|
|
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
|
|
|
|
narration = description
|
|
if fiat_currency and fiat_amount_abs:
|
|
narration += f" ({fiat_amount_abs:.2f} {fiat_currency})"
|
|
|
|
postings = [
|
|
format_posting_with_cost(
|
|
account=payment_account,
|
|
amount_sats=amount_sats_abs, # Positive = debit (asset increase)
|
|
fiat_currency=fiat_currency,
|
|
fiat_amount=fiat_amount_abs
|
|
),
|
|
format_posting_with_cost(
|
|
account=revenue_account,
|
|
amount_sats=-amount_sats_abs, # Negative = credit (revenue increase)
|
|
fiat_currency=fiat_currency,
|
|
fiat_amount=fiat_amount_abs
|
|
)
|
|
]
|
|
|
|
# Note: created-via is redundant with #revenue-entry tag
|
|
entry_meta = {
|
|
"source": "castle-api"
|
|
}
|
|
|
|
links = []
|
|
if reference:
|
|
links.append(reference)
|
|
|
|
return format_transaction(
|
|
date_val=entry_date,
|
|
flag="*", # Cleared (payment received)
|
|
narration=narration,
|
|
postings=postings,
|
|
tags=["revenue-entry"],
|
|
links=links,
|
|
meta=entry_meta
|
|
)
|