Resolve entry identity via entry-id metadata; unfuse user references (libra-#42)
Approving a pending entry created with a reference (e.g. invoice
"42-144") 404'd with "Pending entry unknown not found": the list
endpoints recovered the entry id by parsing links for a libra- prefix,
but reference-bearing entries displace that link with the fused
"{reference}-{entry_id}" form, so the id surfaced as the literal
"unknown" and the approve call round-tripped it.
Make the entry-id transaction metadata the single canonical identity:
- _extract_entry_id() resolves metadata-first (libra- link parsing kept
only for pre-dfdcc44 ledger history); used by /entries/user,
/entries/pending, approve, and reject.
- Creation endpoints no longer fuse the reference with the entry id —
the user reference becomes its own sanitized link and round-trips
verbatim in API responses. Typed exp-/rcv-/inc- links stay as the
settlement-tracking handles.
- format_revenue_entry now writes entry-id metadata like its siblings
and sanitizes its reference link (was appended raw); generic
POST /entries sanitizes its reference link too.
- User-journal reference extraction skips all system link prefixes
(typed links used to leak into the reference field).
Contract documented in CLAUDE.md (Data Integrity → Entry Identity &
Links), pinned by tests/test_entry_identity_api.py and formatter
contract tests in test_unit.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
116df46d38
commit
15d9910073
6 changed files with 374 additions and 89 deletions
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -209,7 +209,8 @@ entry = format_transaction(
|
|||
{"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"}
|
||||
],
|
||||
tags=["groceries"],
|
||||
links=["libra-entry-123"]
|
||||
links=["exp-a1b2c3d4e5f60708"], # typed settlement link; identity goes in entry-id metadata
|
||||
meta={"entry-id": "a1b2c3d4e5f60708"}
|
||||
)
|
||||
|
||||
# Submit to Fava
|
||||
|
|
@ -217,6 +218,8 @@ client = get_fava_client()
|
|||
result = await client.add_entry(entry)
|
||||
```
|
||||
|
||||
Prefer the purpose-built formatters (`format_expense_entry`, `format_income_entry`, …) over raw `format_transaction` — they write the `entry-id` metadata and typed links for you (see Data Integrity → Entry Identity & Links).
|
||||
|
||||
**Querying Balances**:
|
||||
```python
|
||||
# Query user balance from Fava
|
||||
|
|
@ -278,7 +281,8 @@ entry = format_transaction(
|
|||
{"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"}
|
||||
],
|
||||
tags=["utilities"],
|
||||
links=["libra-tx-123"]
|
||||
links=["exp-0123456789abcdef"],
|
||||
meta={"entry-id": "0123456789abcdef"}
|
||||
)
|
||||
|
||||
client = get_fava_client()
|
||||
|
|
@ -306,6 +310,13 @@ result = await client.query(query)
|
|||
3. User accounts use `user_id` (NOT `wallet_id`) for consistency
|
||||
4. All accounting calculations delegated to Beancount/Fava
|
||||
|
||||
**Entry Identity & Links** (the contract `_extract_entry_id()` in views_api.py relies on):
|
||||
- The `entry-id` **transaction metadata** is the single canonical entry identifier. Every libra-authored entry formatter (`format_expense_entry`, `format_receivable_entry`, `format_income_entry`, `format_revenue_entry`) writes it. All id resolution (pending list, user journal, approve, reject) reads it — never parse links to recover an id.
|
||||
- Typed links `exp-{id}` / `rcv-{id}` / `inc-{id}` exist for **settlement tracking** (BQL queries match settlements to source entries by these links). They duplicate the id but are not the identity source.
|
||||
- A user-supplied `reference` (invoice number, receipt id) becomes **its own sanitized link**, verbatim — never fused with the entry id, never displacing a system link. Two entries sharing a reference share the link (desired Beancount semantics).
|
||||
- `ln-{payment_hash[:16]}` links mark Lightning payments.
|
||||
- Legacy ledger history (pre-dfdcc44) carries a single `libra-{id}` link and no `entry-id` metadata — `_extract_entry_id()` falls back to parsing it. Do not write `libra-` links in new code.
|
||||
|
||||
**Validation** is performed in `core/validation.py`:
|
||||
- Pure validation functions for entry correctness before submitting to Fava
|
||||
|
||||
|
|
|
|||
|
|
@ -945,7 +945,8 @@ def format_revenue_entry(
|
|||
entry_date: date,
|
||||
fiat_currency: Optional[str] = None,
|
||||
fiat_amount: Optional[Decimal] = None,
|
||||
reference: Optional[str] = None
|
||||
reference: Optional[str] = None,
|
||||
entry_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Format a revenue entry (libra receives payment directly).
|
||||
|
|
@ -962,7 +963,8 @@ def format_revenue_entry(
|
|||
entry_date: Date of payment
|
||||
fiat_currency: Optional fiat currency
|
||||
fiat_amount: Optional fiat amount (unsigned)
|
||||
reference: Optional reference
|
||||
reference: Optional reference (invoice ID, etc.) — stored as its own link
|
||||
entry_id: Optional unique entry ID (generated if not provided)
|
||||
|
||||
Returns:
|
||||
Fava API entry dict
|
||||
|
|
@ -978,6 +980,9 @@ def format_revenue_entry(
|
|||
fiat_amount=Decimal("50.00")
|
||||
)
|
||||
"""
|
||||
if not entry_id:
|
||||
entry_id = generate_entry_id()
|
||||
|
||||
amount_sats_abs = abs(amount_sats)
|
||||
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
|
||||
|
||||
|
|
@ -1002,12 +1007,13 @@ def format_revenue_entry(
|
|||
|
||||
# Note: created-via is redundant with #revenue-entry tag
|
||||
entry_meta = {
|
||||
"source": "libra-api"
|
||||
"source": "libra-api",
|
||||
"entry-id": entry_id
|
||||
}
|
||||
|
||||
links = []
|
||||
if reference:
|
||||
links.append(reference)
|
||||
links.append(sanitize_link(reference))
|
||||
|
||||
return format_transaction(
|
||||
date_val=entry_date,
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ async def post_expense(
|
|||
expense_account: str,
|
||||
currency: Optional[str] = "EUR",
|
||||
is_equity: bool = False,
|
||||
reference: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""User submits an expense — creates Liability (libra owes user) or Equity contribution.
|
||||
|
||||
|
|
@ -136,6 +137,7 @@ async def post_expense(
|
|||
"user_wallet": user_wallet_id,
|
||||
"currency": currency,
|
||||
"is_equity": is_equity,
|
||||
"reference": reference,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, f"post_expense failed: {r.status_code} {r.text}"
|
||||
|
|
@ -150,6 +152,7 @@ async def post_income(
|
|||
description: str,
|
||||
revenue_account: str,
|
||||
currency: str = "EUR",
|
||||
reference: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""User submits income on libra's behalf — creates Receivable (user owes libra)."""
|
||||
r = await client.post(
|
||||
|
|
@ -160,13 +163,14 @@ async def post_income(
|
|||
"amount": _amount(amount),
|
||||
"revenue_account": revenue_account,
|
||||
"currency": currency,
|
||||
"reference": reference,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, f"post_income failed: {r.status_code} {r.text}"
|
||||
return r.json()
|
||||
|
||||
|
||||
async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> list[dict]:
|
||||
async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> dict[str, Any]:
|
||||
r = await client.get(
|
||||
"/libra/api/v1/entries/user",
|
||||
headers={"X-Api-Key": wallet_inkey},
|
||||
|
|
@ -175,6 +179,18 @@ async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> list[d
|
|||
return r.json()
|
||||
|
||||
|
||||
async def list_pending_entries(
|
||||
client: AsyncClient, *, super_user_headers: dict,
|
||||
) -> list[dict]:
|
||||
"""Admin lists pending (`!`) entries awaiting approval."""
|
||||
r = await client.get(
|
||||
"/libra/api/v1/entries/pending",
|
||||
headers=super_user_headers,
|
||||
)
|
||||
assert r.status_code == 200, f"list_pending_entries failed: {r.status_code} {r.text}"
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entries — admin side
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
168
tests/test_entry_identity_api.py
Normal file
168
tests/test_entry_identity_api.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""Entry identity resolution — the canonical id must survive a user reference.
|
||||
|
||||
Regression coverage for the production bug where a pending income entry
|
||||
created with a `reference` (e.g. an invoice number like "42-144") could
|
||||
not be approved: the admin UI's pending list resolved the entry id by
|
||||
parsing links for a `libra-` prefix, but reference-bearing entries carry
|
||||
typed links (`inc-/exp-/rcv-{id}`) plus the reference as its own link —
|
||||
no `libra-` link. The id surfaced as the literal string "unknown" and
|
||||
`POST /entries/unknown/approve` 404'd.
|
||||
|
||||
The fix makes the `entry-id` transaction metadata the single source of
|
||||
truth (list, approve, and reject endpoints), with link parsing kept only
|
||||
for pre-metadata ledger history. These tests pin that contract:
|
||||
|
||||
- pending list returns the real id for reference-bearing entries
|
||||
- approve/reject resolve that id end-to-end
|
||||
- the user reference round-trips as `reference`, never as a system link
|
||||
"""
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from .helpers import (
|
||||
approve_entry,
|
||||
list_pending_entries,
|
||||
list_user_entries,
|
||||
post_expense,
|
||||
post_income,
|
||||
reject_entry,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pending_income_with_reference_resolves_real_id(
|
||||
client, super_user_headers, configured_user, standard_accounts,
|
||||
):
|
||||
"""The production repro: income + reference must list with its real
|
||||
id (not 'unknown') and approve successfully."""
|
||||
_, wallet = configured_user
|
||||
marker = f"Membership dues {uuid4().hex[:6]}"
|
||||
|
||||
posted = await post_income(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
amount="700.00", currency="EUR",
|
||||
description=marker,
|
||||
revenue_account=standard_accounts["revenue_rent"]["name"],
|
||||
reference="42-144",
|
||||
)
|
||||
|
||||
pending = await list_pending_entries(
|
||||
client, super_user_headers=super_user_headers,
|
||||
)
|
||||
entry = next(
|
||||
(e for e in pending if marker in (e.get("description") or "")), None,
|
||||
)
|
||||
assert entry is not None, f"income entry not in pending list: {pending}"
|
||||
assert entry["id"] == posted["id"], (
|
||||
f"pending list must surface the canonical entry id, "
|
||||
f"got {entry['id']!r} (expected {posted['id']!r})"
|
||||
)
|
||||
assert entry["id"] != "unknown"
|
||||
|
||||
# The id from the listing must drive approval end-to-end.
|
||||
result = await approve_entry(
|
||||
client, super_user_headers=super_user_headers, entry_id=entry["id"],
|
||||
)
|
||||
assert result.get("entry_id") == posted["id"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pending_expense_with_reference_resolves_real_id_and_rejects(
|
||||
client, super_user_headers, configured_user, standard_accounts,
|
||||
):
|
||||
"""Same contract on the expense path, exercised through reject."""
|
||||
_, wallet = configured_user
|
||||
marker = f"Receipted groceries {uuid4().hex[:6]}"
|
||||
|
||||
posted = await post_expense(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
user_wallet_id=wallet.id,
|
||||
amount="36.93", currency="EUR",
|
||||
description=marker,
|
||||
expense_account=standard_accounts["expense_food"]["name"],
|
||||
reference="RECEIPT/2026-06-12",
|
||||
)
|
||||
|
||||
pending = await list_pending_entries(
|
||||
client, super_user_headers=super_user_headers,
|
||||
)
|
||||
entry = next(
|
||||
(e for e in pending if marker in (e.get("description") or "")), None,
|
||||
)
|
||||
assert entry is not None, f"expense entry not in pending list: {pending}"
|
||||
assert entry["id"] == posted["id"]
|
||||
|
||||
result = await reject_entry(
|
||||
client, super_user_headers=super_user_headers, entry_id=entry["id"],
|
||||
)
|
||||
assert result.get("entry_id") == posted["id"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reference_round_trips_in_user_journal(
|
||||
client, configured_user, standard_accounts,
|
||||
):
|
||||
"""The user journal must report the user's reference, not a system
|
||||
link (typed inc-/exp- links used to leak into the reference field)."""
|
||||
_, wallet = configured_user
|
||||
marker = f"Referenced expense {uuid4().hex[:6]}"
|
||||
|
||||
posted = await post_expense(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
user_wallet_id=wallet.id,
|
||||
amount="12.00", currency="EUR",
|
||||
description=marker,
|
||||
expense_account=standard_accounts["expense_food"]["name"],
|
||||
reference="INV-7731",
|
||||
)
|
||||
assert posted.get("reference") == "INV-7731"
|
||||
|
||||
listing = await list_user_entries(client, wallet_inkey=wallet.inkey)
|
||||
entry = next(
|
||||
(
|
||||
e for e in listing.get("entries", [])
|
||||
if marker in (e.get("description") or "")
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert entry is not None
|
||||
assert entry["id"] == posted["id"]
|
||||
assert entry.get("reference") == "INV-7731", (
|
||||
f"reference field must carry the user's reference, "
|
||||
f"got {entry.get('reference')!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_entry_without_reference_still_resolves(
|
||||
client, super_user_headers, configured_user, standard_accounts,
|
||||
):
|
||||
"""No-reference entries keep working (the case that always worked)."""
|
||||
_, wallet = configured_user
|
||||
marker = f"Plain income {uuid4().hex[:6]}"
|
||||
|
||||
posted = await post_income(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
amount="55.00", currency="EUR",
|
||||
description=marker,
|
||||
revenue_account=standard_accounts["revenue_rent"]["name"],
|
||||
)
|
||||
|
||||
pending = await list_pending_entries(
|
||||
client, super_user_headers=super_user_headers,
|
||||
)
|
||||
entry = next(
|
||||
(e for e in pending if marker in (e.get("description") or "")), None,
|
||||
)
|
||||
assert entry is not None
|
||||
assert entry["id"] == posted["id"]
|
||||
|
||||
result = await approve_entry(
|
||||
client, super_user_headers=super_user_headers, entry_id=entry["id"],
|
||||
)
|
||||
assert result.get("entry_id") == posted["id"]
|
||||
|
|
@ -135,6 +135,96 @@ def test_generate_entry_ids_are_unique():
|
|||
assert len(ids) == 100 # 16 hex chars = 64 bits; collisions in 100 are negligible
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry identity contract — every libra-authored entry formatter must write
|
||||
# `entry-id` metadata (the canonical id) and keep the user reference as its
|
||||
# own sanitized link, never fused with the id.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_format_expense_entry_identity_contract():
|
||||
entry = bf.format_expense_entry(
|
||||
user_id="abc12345",
|
||||
expense_account="Expenses:Food",
|
||||
user_account="Liabilities:Payable:User-abc12345",
|
||||
amount_sats=50000,
|
||||
description="Groceries",
|
||||
entry_date=date(2026, 6, 12),
|
||||
fiat_currency="EUR",
|
||||
fiat_amount=Decimal("46.50"),
|
||||
reference="Invoice #123",
|
||||
entry_id="deadbeef00000001",
|
||||
)
|
||||
assert entry["meta"]["entry-id"] == "deadbeef00000001"
|
||||
assert "exp-deadbeef00000001" in entry["links"]
|
||||
assert "Invoice-123" in entry["links"] # sanitized, standalone
|
||||
|
||||
|
||||
def test_format_receivable_entry_identity_contract():
|
||||
entry = bf.format_receivable_entry(
|
||||
user_id="abc12345",
|
||||
revenue_account="Income:Accommodation",
|
||||
receivable_account="Assets:Receivable:User-abc12345",
|
||||
amount_sats=100000,
|
||||
description="2-night stay",
|
||||
entry_date=date(2026, 6, 12),
|
||||
fiat_currency="EUR",
|
||||
fiat_amount=Decimal("93.00"),
|
||||
reference="BOOKING/42",
|
||||
entry_id="deadbeef00000002",
|
||||
)
|
||||
assert entry["meta"]["entry-id"] == "deadbeef00000002"
|
||||
assert "rcv-deadbeef00000002" in entry["links"]
|
||||
assert "BOOKING/42" in entry["links"]
|
||||
|
||||
|
||||
def test_format_income_entry_identity_contract():
|
||||
"""The production-bug shape: income + reference like '42-144'."""
|
||||
entry = bf.format_income_entry(
|
||||
user_id="abc12345",
|
||||
user_account="Assets:Receivable:User-abc12345",
|
||||
revenue_account="Income:MemberDuesContributions",
|
||||
amount_sats=1112490,
|
||||
description="2 Memberships",
|
||||
entry_date=date(2026, 6, 12),
|
||||
fiat_currency="USD",
|
||||
fiat_amount=Decimal("700.00"),
|
||||
reference="42-144",
|
||||
entry_id="deadbeef00000003",
|
||||
)
|
||||
assert entry["meta"]["entry-id"] == "deadbeef00000003"
|
||||
assert "inc-deadbeef00000003" in entry["links"]
|
||||
assert "42-144" in entry["links"]
|
||||
|
||||
|
||||
def test_format_revenue_entry_identity_contract():
|
||||
entry = bf.format_revenue_entry(
|
||||
payment_account="Assets:Cash",
|
||||
revenue_account="Income:Sales",
|
||||
amount_sats=100000,
|
||||
description="Product sale",
|
||||
entry_date=date(2026, 6, 12),
|
||||
fiat_currency="EUR",
|
||||
fiat_amount=Decimal("50.00"),
|
||||
reference="Till receipt 9",
|
||||
entry_id="deadbeef00000004",
|
||||
)
|
||||
assert entry["meta"]["entry-id"] == "deadbeef00000004"
|
||||
assert "Till-receipt-9" in entry["links"] # sanitized
|
||||
|
||||
|
||||
def test_format_revenue_entry_generates_entry_id_when_absent():
|
||||
entry = bf.format_revenue_entry(
|
||||
payment_account="Assets:Cash",
|
||||
revenue_account="Income:Sales",
|
||||
amount_sats=100000,
|
||||
description="Product sale",
|
||||
entry_date=date(2026, 6, 12),
|
||||
)
|
||||
eid = entry["meta"]["entry-id"]
|
||||
assert len(eid) == 16 and all(c in "0123456789abcdef" for c in eid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# account_utils.format_hierarchical_account_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
154
views_api.py
154
views_api.py
|
|
@ -434,6 +434,39 @@ async def api_get_journal_entries(
|
|||
return enriched_entries
|
||||
|
||||
|
||||
# Link prefixes written by libra itself (vs user-supplied references):
|
||||
# exp-/rcv-/inc- typed entry links, ln- lightning payment links, and the
|
||||
# legacy libra-{id} identity link.
|
||||
_SYSTEM_LINK_PREFIXES = ("exp-", "rcv-", "inc-", "ln-", "libra-")
|
||||
|
||||
|
||||
def _extract_entry_id(entry: dict) -> Optional[str]:
|
||||
"""Resolve the canonical libra entry id for a Fava transaction.
|
||||
|
||||
The ``entry-id`` transaction metadata is the single source of truth —
|
||||
written by every libra entry formatter since dfdcc44. Ledger history
|
||||
predating it carries only a ``libra-{id}`` link; parse that as a
|
||||
fallback so old entries still resolve.
|
||||
|
||||
Returns None when no id can be determined (e.g. settlement/payment
|
||||
transactions, which are not approvable).
|
||||
"""
|
||||
meta = entry.get("meta", {})
|
||||
entry_id = meta.get("entry-id")
|
||||
if entry_id:
|
||||
return str(entry_id)
|
||||
|
||||
# Legacy fallback: pre-entry-id ledger history (single libra-{id} link)
|
||||
links = entry.get("links", [])
|
||||
if isinstance(links, (list, set)):
|
||||
for link in links:
|
||||
if isinstance(link, str):
|
||||
link_clean = link.lstrip('^')
|
||||
if link_clean.startswith("libra-"):
|
||||
return link_clean[len("libra-"):]
|
||||
return None
|
||||
|
||||
|
||||
@libra_api_router.get("/api/v1/entries/user")
|
||||
async def api_get_user_entries(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
|
|
@ -524,18 +557,9 @@ async def api_get_user_entries(
|
|||
continue
|
||||
|
||||
# Extract data for frontend
|
||||
# Extract entry ID from links
|
||||
entry_id = None
|
||||
# Resolve canonical entry ID (metadata first, link fallback)
|
||||
entry_id = _extract_entry_id(e)
|
||||
links = e.get("links", [])
|
||||
if isinstance(links, (list, set)):
|
||||
for link in links:
|
||||
if isinstance(link, str):
|
||||
link_clean = link.lstrip('^')
|
||||
if "libra-" in link_clean:
|
||||
parts = link_clean.split("libra-")
|
||||
if len(parts) > 1:
|
||||
entry_id = parts[-1]
|
||||
break
|
||||
|
||||
# Extract amount from postings
|
||||
amount_sats = 0
|
||||
|
|
@ -592,13 +616,15 @@ async def api_get_user_entries(
|
|||
fiat_amount = float(cost_match.group(1))
|
||||
fiat_currency = cost_match.group(2)
|
||||
|
||||
# Extract reference from links (first non-libra link)
|
||||
# Extract reference from links (first link that isn't a
|
||||
# libra-system link: typed entry/settlement links, lightning
|
||||
# payment links, or the legacy libra-{id} identity link)
|
||||
reference = None
|
||||
if isinstance(links, (list, set)):
|
||||
for link in links:
|
||||
if isinstance(link, str):
|
||||
link_clean = link.lstrip('^')
|
||||
if not link_clean.startswith("libra-") and not link_clean.startswith("ln-"):
|
||||
if not link_clean.startswith(_SYSTEM_LINK_PREFIXES):
|
||||
reference = link_clean
|
||||
break
|
||||
|
||||
|
|
@ -778,19 +804,9 @@ async def api_get_pending_entries(
|
|||
for e in all_entries:
|
||||
# Only include pending transactions that are NOT voided
|
||||
if e.get("t") == "Transaction" and e.get("flag") == "!" and "voided" not in e.get("tags", []):
|
||||
# Extract entry ID from links field
|
||||
entry_id = None
|
||||
# Resolve canonical entry ID (metadata first, link fallback)
|
||||
entry_id = _extract_entry_id(e)
|
||||
links = e.get("links", [])
|
||||
if isinstance(links, (list, set)):
|
||||
for link in links:
|
||||
if isinstance(link, str):
|
||||
# Strip ^ prefix if present (Beancount link syntax)
|
||||
link_clean = link.lstrip('^')
|
||||
if "libra-" in link_clean:
|
||||
parts = link_clean.split("libra-")
|
||||
if len(parts) > 1:
|
||||
entry_id = parts[-1]
|
||||
break
|
||||
|
||||
# Extract user ID from metadata or account names
|
||||
user_id = None
|
||||
|
|
@ -906,7 +922,11 @@ async def api_create_journal_entry(
|
|||
Submits entry to Fava/Beancount.
|
||||
"""
|
||||
from .fava_client import get_fava_client
|
||||
from .beancount_format import format_transaction, format_posting_with_cost
|
||||
from .beancount_format import (
|
||||
format_transaction,
|
||||
format_posting_with_cost,
|
||||
sanitize_link,
|
||||
)
|
||||
|
||||
# Validate that entry balances to zero
|
||||
total = sum(line.amount for line in data.lines)
|
||||
|
|
@ -975,7 +995,7 @@ async def api_create_journal_entry(
|
|||
tags = data.meta.get("tags", [])
|
||||
links = data.meta.get("links", [])
|
||||
if data.reference:
|
||||
links.append(data.reference)
|
||||
links.append(sanitize_link(data.reference))
|
||||
|
||||
# Entry metadata (excluding tags and links which go at transaction level)
|
||||
entry_meta = {k: v for k, v in data.meta.items() if k not in ["tags", "links"]}
|
||||
|
|
@ -1128,7 +1148,7 @@ async def api_create_expense_entry(
|
|||
|
||||
# Format as Beancount entry and submit to Fava
|
||||
from .fava_client import get_fava_client
|
||||
from .beancount_format import format_expense_entry, sanitize_link
|
||||
from .beancount_format import format_expense_entry
|
||||
|
||||
fava = get_fava_client()
|
||||
|
||||
|
|
@ -1140,12 +1160,8 @@ async def api_create_expense_entry(
|
|||
import uuid
|
||||
entry_id = str(uuid.uuid4()).replace("-", "")[:16]
|
||||
|
||||
# Add libra ID as reference/link (sanitized for Beancount)
|
||||
libra_reference = f"libra-{entry_id}"
|
||||
if data.reference:
|
||||
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
|
||||
|
||||
# Format Beancount entry
|
||||
# Format Beancount entry. Identity travels as entry-id metadata +
|
||||
# exp-{entry_id} link; the user reference becomes its own link.
|
||||
entry = format_expense_entry(
|
||||
user_id=wallet.wallet.user,
|
||||
expense_account=expense_account.name,
|
||||
|
|
@ -1156,8 +1172,8 @@ async def api_create_expense_entry(
|
|||
is_equity=data.is_equity,
|
||||
fiat_currency=fiat_currency,
|
||||
fiat_amount=fiat_amount,
|
||||
reference=libra_reference,
|
||||
entry_id=entry_id # Pass entry_id so all links match
|
||||
reference=data.reference,
|
||||
entry_id=entry_id
|
||||
)
|
||||
|
||||
# Submit to Fava
|
||||
|
|
@ -1171,7 +1187,7 @@ async def api_create_expense_entry(
|
|||
entry_date=data.entry_date if data.entry_date else datetime.now(),
|
||||
created_by=wallet.wallet.user, # Use user_id, not wallet_id
|
||||
created_at=datetime.now(),
|
||||
reference=libra_reference,
|
||||
reference=data.reference,
|
||||
flag=JournalEntryFlag.PENDING,
|
||||
meta=entry_meta,
|
||||
lines=[
|
||||
|
|
@ -1262,17 +1278,15 @@ async def api_create_income_entry(
|
|||
|
||||
# Submit to Fava
|
||||
from .fava_client import get_fava_client
|
||||
from .beancount_format import format_income_entry, sanitize_link
|
||||
from .beancount_format import format_income_entry
|
||||
|
||||
fava = get_fava_client()
|
||||
|
||||
import uuid
|
||||
entry_id = str(uuid.uuid4()).replace("-", "")[:16]
|
||||
|
||||
libra_reference = f"libra-{entry_id}"
|
||||
if data.reference:
|
||||
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
|
||||
|
||||
# Identity travels as entry-id metadata + inc-{entry_id} link; the
|
||||
# user reference becomes its own link.
|
||||
entry = format_income_entry(
|
||||
user_id=wallet.wallet.user,
|
||||
user_account=user_account.name,
|
||||
|
|
@ -1282,7 +1296,7 @@ async def api_create_income_entry(
|
|||
entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(),
|
||||
fiat_currency=fiat_currency,
|
||||
fiat_amount=data.amount,
|
||||
reference=libra_reference,
|
||||
reference=data.reference,
|
||||
entry_id=entry_id,
|
||||
)
|
||||
|
||||
|
|
@ -1303,7 +1317,7 @@ async def api_create_income_entry(
|
|||
entry_date=data.entry_date if data.entry_date else datetime.now(),
|
||||
created_by=wallet.wallet.user,
|
||||
created_at=datetime.now(),
|
||||
reference=libra_reference,
|
||||
reference=data.reference,
|
||||
flag=JournalEntryFlag.PENDING,
|
||||
meta=entry_meta,
|
||||
lines=[
|
||||
|
|
@ -1389,7 +1403,7 @@ async def api_create_receivable_entry(
|
|||
|
||||
# Format as Beancount entry and submit to Fava
|
||||
from .fava_client import get_fava_client
|
||||
from .beancount_format import format_receivable_entry, sanitize_link
|
||||
from .beancount_format import format_receivable_entry
|
||||
|
||||
fava = get_fava_client()
|
||||
|
||||
|
|
@ -1401,12 +1415,8 @@ async def api_create_receivable_entry(
|
|||
import uuid
|
||||
entry_id = str(uuid.uuid4()).replace("-", "")[:16]
|
||||
|
||||
# Add libra ID as reference/link (sanitized for Beancount)
|
||||
libra_reference = f"libra-{entry_id}"
|
||||
if data.reference:
|
||||
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
|
||||
|
||||
# Format Beancount entry
|
||||
# Format Beancount entry. Identity travels as entry-id metadata +
|
||||
# rcv-{entry_id} link; the user reference becomes its own link.
|
||||
entry = format_receivable_entry(
|
||||
user_id=data.user_id,
|
||||
revenue_account=revenue_account.name,
|
||||
|
|
@ -1416,8 +1426,8 @@ async def api_create_receivable_entry(
|
|||
entry_date=datetime.now().date(),
|
||||
fiat_currency=fiat_currency,
|
||||
fiat_amount=fiat_amount,
|
||||
reference=libra_reference,
|
||||
entry_id=entry_id # Pass entry_id so all links match
|
||||
reference=data.reference,
|
||||
entry_id=entry_id
|
||||
)
|
||||
|
||||
# Submit to Fava
|
||||
|
|
@ -1431,7 +1441,7 @@ async def api_create_receivable_entry(
|
|||
entry_date=datetime.now(),
|
||||
created_by=auth.user_id,
|
||||
created_at=datetime.now(),
|
||||
reference=libra_reference, # Use libra reference with unique ID
|
||||
reference=data.reference,
|
||||
flag=JournalEntryFlag.PENDING,
|
||||
meta=entry_meta,
|
||||
lines=[
|
||||
|
|
@ -1467,7 +1477,7 @@ async def api_create_revenue_entry(
|
|||
Submits entry to Fava/Beancount.
|
||||
"""
|
||||
from .fava_client import get_fava_client
|
||||
from .beancount_format import format_revenue_entry, sanitize_link
|
||||
from .beancount_format import format_revenue_entry
|
||||
|
||||
# Get revenue account
|
||||
revenue_account = await get_account_by_name(data.revenue_account)
|
||||
|
|
@ -1517,11 +1527,8 @@ async def api_create_revenue_entry(
|
|||
import uuid
|
||||
entry_id = str(uuid.uuid4()).replace("-", "")[:16]
|
||||
|
||||
# Add libra ID as reference/link (sanitized for Beancount)
|
||||
libra_reference = f"libra-{entry_id}"
|
||||
if data.reference:
|
||||
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
|
||||
|
||||
# Identity travels as entry-id metadata; the user reference becomes
|
||||
# its own link.
|
||||
entry = format_revenue_entry(
|
||||
payment_account=payment_account.name,
|
||||
revenue_account=revenue_account.name,
|
||||
|
|
@ -1530,7 +1537,8 @@ async def api_create_revenue_entry(
|
|||
entry_date=datetime.now().date(),
|
||||
fiat_currency=fiat_currency,
|
||||
fiat_amount=fiat_amount,
|
||||
reference=libra_reference # Use libra reference with unique ID
|
||||
reference=data.reference,
|
||||
entry_id=entry_id,
|
||||
)
|
||||
|
||||
# Submit to Fava
|
||||
|
|
@ -1545,7 +1553,7 @@ async def api_create_revenue_entry(
|
|||
entry_date=datetime.now(),
|
||||
created_by=auth.user_id,
|
||||
created_at=datetime.now(),
|
||||
reference=libra_reference,
|
||||
reference=data.reference,
|
||||
flag=JournalEntryFlag.CLEARED,
|
||||
lines=[], # Empty - entry is stored in Fava, not Libra DB
|
||||
meta={"source": "fava", "fava_response": result.get('data', 'Unknown')}
|
||||
|
|
@ -2857,22 +2865,15 @@ async def api_approve_expense_entry(
|
|||
# 1. Get all journal entries from Fava
|
||||
all_entries = await fava.get_journal_entries()
|
||||
|
||||
# 2. Find the entry with matching libra ID in links
|
||||
# 2. Find the pending transaction with matching canonical entry id
|
||||
target_entry = None
|
||||
|
||||
for entry in all_entries:
|
||||
# Only look at transactions with pending flag
|
||||
if entry.get("t") == "Transaction" and entry.get("flag") == "!":
|
||||
links = entry.get("links", [])
|
||||
for link in links:
|
||||
# Strip ^ prefix if present (Beancount link syntax)
|
||||
link_clean = link.lstrip('^')
|
||||
# Check if this entry has our libra ID
|
||||
if link_clean == f"libra-{entry_id}" or link_clean.endswith(f"-{entry_id}"):
|
||||
if _extract_entry_id(entry) == entry_id:
|
||||
target_entry = entry
|
||||
break
|
||||
if target_entry:
|
||||
break
|
||||
|
||||
if not target_entry:
|
||||
raise HTTPException(
|
||||
|
|
@ -2973,22 +2974,15 @@ async def api_reject_expense_entry(
|
|||
# 1. Get all journal entries from Fava
|
||||
all_entries = await fava.get_journal_entries()
|
||||
|
||||
# 2. Find the entry with matching libra ID in links
|
||||
# 2. Find the pending transaction with matching canonical entry id
|
||||
target_entry = None
|
||||
|
||||
for entry in all_entries:
|
||||
# Only look at transactions with pending flag
|
||||
if entry.get("t") == "Transaction" and entry.get("flag") == "!":
|
||||
links = entry.get("links", [])
|
||||
for link in links:
|
||||
# Strip ^ prefix if present (Beancount link syntax)
|
||||
link_clean = link.lstrip('^')
|
||||
# Check if this entry has our libra ID
|
||||
if link_clean == f"libra-{entry_id}" or link_clean.endswith(f"-{entry_id}"):
|
||||
if _extract_entry_id(entry) == entry_id:
|
||||
target_entry = entry
|
||||
break
|
||||
if target_entry:
|
||||
break
|
||||
|
||||
if not target_entry:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue