One pass over the LOW-tier review items plus two folded issues: - Delete validate_journal_entry (dead since the Fava migration; it validated the pre-string-amount model) with its exports, unused crud imports, and tests. Beancount validates entries now. - Migration m006: UNIQUE index on user_roles(user_id, role_id) after deduping; assign_user_role inserts with ON CONFLICT DO NOTHING and returns the existing assignment — closes the auto-assign check-then-act race on concurrent logins. - Extract _get_username_from_user_id (110 lines in views_api, fresh LNbits Database per call inside per-row hot paths) into user_lookup.py with one shared core-DB handle, a 60s TTL cache and a batch get_usernames API (review #18). - Receivable-entry responses report CLEARED, matching the flag the formatter actually writes; PENDING misled the UI (libra-#35). - Replace the remaining print() calls in tasks.py with logger. - get_all_accounts derives valid roots from account_utils.ACCOUNT_TYPE_ROOTS instead of a hardcoded tuple, and the no-op per-test rate-limit reset is gone (libra-#54). - Delete migrations_old.py.bak, MIGRATION_SQUASH_SUMMARY.md, docs/PHASE*_COMPLETE.md and the rendered .html; .gitignore data/ (it holds the runtime .lnbits_auth_key secret). - Track docs/CODE-REVIEW-2026-06.md with finding statuses updated for the PR #55-#59 + chore/hygiene series. - CLAUDE.md notes LNbits pins Pydantic v1: keep .dict(), don't "modernize" to .model_dump(). Note: format_payment_entry's is_payable docstring (flagged in review follow-up) turned out to be consistent with the body — no change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
615 lines
22 KiB
Python
615 lines
22 KiB
Python
"""Pure-function unit tests — no harness, no Fava, no LNbits app.
|
|
|
|
Covers `libra.beancount_format`, `libra.account_utils`, `libra.core.validation`.
|
|
These modules have no external dependencies (stdlib + pydantic for models), so
|
|
they run fast and don't need fixtures.
|
|
|
|
The libra package is importable under either `lnbits.extensions.libra.*`
|
|
(default lnbits layout) or `libra.*` (LNBITS_EXTENSIONS_PATH override). The
|
|
`_module` helper tries both, mirroring the runtime-path discipline already
|
|
established in `conftest.py`.
|
|
"""
|
|
import importlib
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
|
|
def _module(name: str):
|
|
"""Import a libra submodule under whichever path the active LNbits layout
|
|
uses (default `lnbits.extensions.libra` or bare `libra`)."""
|
|
for prefix in ("lnbits.extensions.libra", "libra"):
|
|
try:
|
|
return importlib.import_module(f"{prefix}.{name}")
|
|
except ModuleNotFoundError:
|
|
continue
|
|
raise ModuleNotFoundError(f"libra.{name}: tried both import paths")
|
|
|
|
|
|
bf = _module("beancount_format")
|
|
au = _module("account_utils")
|
|
val = _module("core.validation")
|
|
mdl = _module("models")
|
|
fc = _module("fava_client")
|
|
AccountType = mdl.AccountType
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fava_client._open_directive_exists — duplicate-account detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_open_directive_exists_matches_real_directive():
|
|
src = "2020-01-01 open Expenses:Vehicle:Gas\n"
|
|
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True
|
|
|
|
|
|
def test_open_directive_exists_matches_currency_constrained_and_metadata():
|
|
src = (
|
|
"2020-01-01 open Expenses:Vehicle:Gas EUR, SATS\n"
|
|
' added_by: "abc"\n'
|
|
)
|
|
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True
|
|
|
|
|
|
def test_open_directive_exists_matches_inline_comment_without_space():
|
|
# Valid Beancount: the account token ends at ';'. The old (?:\\s|$) boundary
|
|
# missed this → duplicate Open written → bean-check breaks.
|
|
src = "2020-01-01 open Expenses:Vehicle:Gas;legacy-import\n"
|
|
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True
|
|
|
|
|
|
def test_open_directive_exists_ignores_name_inside_description():
|
|
# The name appears only inside another account's description metadata.
|
|
src = (
|
|
"2020-01-01 open Expenses:Notes\n"
|
|
' description: "remember to open Expenses:Vehicle:Gas next month"\n'
|
|
)
|
|
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
|
|
|
|
|
|
def test_open_directive_exists_ignores_comment_line():
|
|
src = "; TODO: open Expenses:Vehicle:Gas eventually\n"
|
|
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
|
|
|
|
|
|
def test_open_directive_exists_does_not_match_longer_sibling():
|
|
src = "2020-01-01 open Expenses:Vehicle:GasStation\n"
|
|
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
|
|
|
|
|
|
def test_open_directive_exists_does_not_match_deeper_child():
|
|
src = "2020-01-01 open Expenses:Vehicle:Gas:Premium\n"
|
|
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"line",
|
|
[
|
|
"2024/3/5 open Expenses:Vehicle:Gas", # slash date, single-digit M/D
|
|
"2020-1-1 open Expenses:Vehicle:Gas", # dash date, single-digit M/D
|
|
"2020-01-01 open Expenses:Vehicle:Gas", # multiple spaces
|
|
"2020-01-01\topen\tExpenses:Vehicle:Gas", # tab separators
|
|
"1970-01-01 open Expenses:Vehicle:Gas EUR", # currency-constrained
|
|
],
|
|
)
|
|
def test_open_directive_exists_matches_beancount_date_and_whitespace_variants(line):
|
|
# All of these are valid Beancount Open directives per lexer.l's DATE token
|
|
# and ignored inter-token whitespace; each must be detected as existing.
|
|
assert fc._open_directive_exists(line + "\n", "Expenses:Vehicle:Gas") is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# beancount_format.sanitize_link
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw", "expected"),
|
|
[
|
|
("libra-abc123", "libra-abc123"),
|
|
("Invoice #123", "Invoice-123"),
|
|
("Test (pending)", "Test-pending"),
|
|
("a/b.c-d_e", "a/b.c-d_e"), # all permitted chars survive
|
|
("multiple spaces", "multiple-spaces"), # collapsed
|
|
("---leading-trailing---", "leading-trailing"),
|
|
("ascii_only", "ascii_only"),
|
|
],
|
|
)
|
|
def test_sanitize_link_strips_unsafe_chars(raw, expected):
|
|
assert bf.sanitize_link(raw) == expected
|
|
|
|
|
|
def test_sanitize_link_empty_string_stays_empty():
|
|
assert bf.sanitize_link("") == ""
|
|
|
|
|
|
def test_sanitize_link_unicode_replaced_with_hyphens():
|
|
# Non-ascii chars all collapse to single hyphens, stripped from edges.
|
|
result = bf.sanitize_link("café résumé")
|
|
assert all(ch in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/."
|
|
for ch in result), f"unsanitized chars in {result!r}"
|
|
assert not result.startswith("-")
|
|
assert not result.endswith("-")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# beancount_format.format_transaction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_format_transaction_minimum_shape():
|
|
entry = bf.format_transaction(
|
|
date_val=date(2026, 6, 6),
|
|
flag="*",
|
|
narration="hello",
|
|
postings=[{"account": "Assets:Cash", "amount": "10 EUR"}],
|
|
)
|
|
# Fava's required fields.
|
|
assert entry["t"] == "Transaction"
|
|
assert entry["date"] == "2026-06-06"
|
|
assert entry["flag"] == "*"
|
|
assert entry["narration"] == "hello"
|
|
assert entry["payee"] == "" # empty string, not None
|
|
assert entry["tags"] == []
|
|
assert entry["links"] == []
|
|
assert entry["meta"] == {}
|
|
assert entry["postings"] == [{"account": "Assets:Cash", "amount": "10 EUR"}]
|
|
|
|
|
|
def test_format_transaction_optional_fields_are_passed_through():
|
|
entry = bf.format_transaction(
|
|
date_val=date(2026, 6, 6),
|
|
flag="!",
|
|
narration="pending lunch",
|
|
postings=[{"account": "Expenses:Food", "amount": "8 EUR"}],
|
|
payee="Bistro Local",
|
|
tags=["expense-entry"],
|
|
links=["libra-abc123"],
|
|
meta={"user-id": "abc12345"},
|
|
)
|
|
assert entry["flag"] == "!"
|
|
assert entry["payee"] == "Bistro Local"
|
|
assert entry["tags"] == ["expense-entry"]
|
|
assert entry["links"] == ["libra-abc123"]
|
|
assert entry["meta"] == {"user-id": "abc12345"}
|
|
|
|
|
|
def test_format_transaction_does_not_share_mutable_defaults():
|
|
"""Regression guard: passing `tags=None` shouldn't return the same list
|
|
every call (the classic Python mutable-default-argument trap)."""
|
|
a = bf.format_transaction(date(2026, 1, 1), "*", "a", [{"account": "X", "amount": "1 EUR"}])
|
|
b = bf.format_transaction(date(2026, 1, 2), "*", "b", [{"account": "Y", "amount": "1 EUR"}])
|
|
a["tags"].append("touched-a")
|
|
assert b["tags"] == [], "tags from one entry leaked into another"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# beancount_format.generate_entry_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_generate_entry_id_shape():
|
|
eid = bf.generate_entry_id()
|
|
assert len(eid) == 16
|
|
assert all(c in "0123456789abcdef" for c in eid), f"non-hex in {eid!r}"
|
|
|
|
|
|
def test_generate_entry_ids_are_unique():
|
|
ids = {bf.generate_entry_id() for _ in range(100)}
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_format_hierarchical_simple_asset():
|
|
assert au.format_hierarchical_account_name(AccountType.ASSET, "Cash") == "Assets:Cash"
|
|
|
|
|
|
def test_format_hierarchical_user_specific_uses_8_char_prefix():
|
|
full_user_id = "af983632aabbccddeeff00112233445566"
|
|
name = au.format_hierarchical_account_name(
|
|
AccountType.ASSET, "Accounts Receivable", user_id=full_user_id,
|
|
)
|
|
assert name == "Assets:Receivable:User-af983632" # 8-char prefix, "Accounts " stripped
|
|
|
|
|
|
def test_format_hierarchical_ampersand_expands_to_colon():
|
|
"""`Food & Supplies` is a legacy display form; it becomes a hierarchy."""
|
|
name = au.format_hierarchical_account_name(AccountType.EXPENSE, "Food & Supplies")
|
|
assert name == "Expenses:Food:Supplies"
|
|
|
|
|
|
def test_format_hierarchical_revenue_uses_income_root():
|
|
"""Beancount uses `Income`, not `Revenue` — the mapping is in
|
|
`ACCOUNT_TYPE_ROOTS`."""
|
|
name = au.format_hierarchical_account_name(AccountType.REVENUE, "Accommodation")
|
|
assert name == "Income:Accommodation"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# account_utils.parse_legacy_account_name
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_legacy_with_user_suffix():
|
|
assert au.parse_legacy_account_name("Accounts Receivable - af983632") == (
|
|
"Accounts Receivable", "af983632",
|
|
)
|
|
|
|
|
|
def test_parse_legacy_without_user_suffix():
|
|
assert au.parse_legacy_account_name("Cash") == ("Cash", None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# account_utils.format_account_display_name
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("hierarchical", "expected"),
|
|
[
|
|
("Assets:Receivable:User-af983632", "Accounts Receivable - af983632"),
|
|
("Liabilities:Payable:User-cafebabe", "Accounts Payable - cafebabe"),
|
|
("Expenses:Food:Supplies", "Food & Supplies"),
|
|
("Assets:Cash", "Cash"),
|
|
("Assets", "Assets"), # too short — passes through
|
|
],
|
|
)
|
|
def test_format_account_display_name(hierarchical, expected):
|
|
assert au.format_account_display_name(hierarchical) == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# account_utils.get_account_type_from_hierarchical
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("name", "expected_type"),
|
|
[
|
|
("Assets:Cash", AccountType.ASSET),
|
|
("Liabilities:Payable:User-x", AccountType.LIABILITY),
|
|
("Equity:User-x", AccountType.EQUITY),
|
|
("Income:Accommodation", AccountType.REVENUE),
|
|
("Expenses:Food", AccountType.EXPENSE),
|
|
],
|
|
)
|
|
def test_get_account_type_from_hierarchical(name, expected_type):
|
|
assert au.get_account_type_from_hierarchical(name) == expected_type
|
|
|
|
|
|
def test_get_account_type_unknown_root_returns_none():
|
|
assert au.get_account_type_from_hierarchical("Other:Random") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# account_utils.migrate_account_name — round-trip legacy → hierarchical
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_migrate_account_name_receivable():
|
|
out = au.migrate_account_name("Accounts Receivable - af983632", AccountType.ASSET)
|
|
assert out == "Assets:Receivable:User-af983632"
|
|
|
|
|
|
def test_migrate_account_name_expense_with_ampersand():
|
|
assert au.migrate_account_name("Food & Supplies", AccountType.EXPENSE) == (
|
|
"Expenses:Food:Supplies"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# core.validation — validate_balance
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_balance_exact_match_passes():
|
|
val.validate_balance("acct", expected_balance_sats=1000, actual_balance_sats=1000)
|
|
|
|
|
|
def test_validate_balance_within_tolerance_passes():
|
|
val.validate_balance(
|
|
"acct", expected_balance_sats=1000, actual_balance_sats=1005, tolerance_sats=10,
|
|
)
|
|
|
|
|
|
def test_validate_balance_outside_tolerance_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_balance(
|
|
"acct", expected_balance_sats=1000, actual_balance_sats=1100, tolerance_sats=10,
|
|
)
|
|
assert "Balance assertion failed" in str(exc.value)
|
|
|
|
|
|
def test_validate_balance_fiat_mismatch_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_balance(
|
|
"acct",
|
|
expected_balance_sats=1000,
|
|
actual_balance_sats=1000,
|
|
expected_balance_fiat=Decimal("100.00"),
|
|
actual_balance_fiat=Decimal("99.50"),
|
|
tolerance_fiat=Decimal("0.10"),
|
|
fiat_currency="EUR",
|
|
)
|
|
assert "Fiat balance" in str(exc.value)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# core.validation — entry-specific validators
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_receivable_entry_positive_revenue_passes():
|
|
val.validate_receivable_entry("u", amount=100, revenue_account_type="revenue")
|
|
|
|
|
|
def test_validate_receivable_entry_zero_amount_raises():
|
|
with pytest.raises(val.ValidationError):
|
|
val.validate_receivable_entry("u", amount=0, revenue_account_type="revenue")
|
|
|
|
|
|
def test_validate_receivable_entry_wrong_account_type_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_receivable_entry("u", amount=100, revenue_account_type="expense")
|
|
assert "revenue account" in str(exc.value)
|
|
|
|
|
|
def test_validate_expense_entry_non_equity_requires_expense_account():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_expense_entry(
|
|
"u", amount=100, expense_account_type="asset", is_equity=False,
|
|
)
|
|
assert "expense account" in str(exc.value)
|
|
|
|
|
|
def test_validate_expense_entry_equity_allows_non_expense_account():
|
|
"""Equity contributions bypass the expense-account requirement."""
|
|
val.validate_expense_entry(
|
|
"u", amount=100, expense_account_type="equity", is_equity=True,
|
|
)
|
|
|
|
|
|
def test_validate_payment_entry_negative_raises():
|
|
with pytest.raises(val.ValidationError):
|
|
val.validate_payment_entry("u", amount=-1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# core.validation — validate_metadata
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_metadata_required_keys_missing_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_metadata({"foo": 1}, required_keys=["bar", "baz"])
|
|
assert "bar" in str(exc.value) and "baz" in str(exc.value)
|
|
|
|
|
|
def test_validate_metadata_fiat_currency_without_amount_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_metadata({"fiat_currency": "EUR"})
|
|
assert "both be present or both absent" in str(exc.value)
|
|
|
|
|
|
def test_validate_metadata_fiat_amount_without_currency_raises():
|
|
with pytest.raises(val.ValidationError):
|
|
val.validate_metadata({"fiat_amount": "10.00"})
|
|
|
|
|
|
def test_validate_metadata_fiat_amount_invalid_decimal_raises():
|
|
with pytest.raises(val.ValidationError) as exc:
|
|
val.validate_metadata({"fiat_amount": "not-a-number", "fiat_currency": "EUR"})
|
|
assert "Invalid fiat_amount" in str(exc.value)
|
|
|
|
|
|
def test_validate_metadata_both_present_passes():
|
|
val.validate_metadata({"fiat_amount": "100.50", "fiat_currency": "EUR"})
|
|
|
|
|
|
def test_validate_metadata_neither_present_passes():
|
|
val.validate_metadata({"source": "api"})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# format_net_settlement_entry — balance guard + credit overflow
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _net_settlement(**overrides):
|
|
kwargs = dict(
|
|
user_id="abc12345",
|
|
payment_account="Assets:Bitcoin:Lightning",
|
|
receivable_account="Assets:Receivable:User-abc12345",
|
|
payable_account="Liabilities:Payable:User-abc12345",
|
|
amount_sats=565251,
|
|
net_fiat_amount=Decimal("517.00"),
|
|
total_receivable_fiat=Decimal("555.00"),
|
|
total_payable_fiat=Decimal("38.00"),
|
|
fiat_currency="EUR",
|
|
description="test settlement",
|
|
entry_date=date(2026, 7, 12),
|
|
payment_hash="ff" * 32,
|
|
)
|
|
kwargs.update(overrides)
|
|
return bf.format_net_settlement_entry(**kwargs)
|
|
|
|
|
|
def test_net_settlement_balanced_passes():
|
|
entry = _net_settlement()
|
|
amounts = [p["amount"] for p in entry["postings"]]
|
|
assert any("@@ 517.00 EUR" in a for a in amounts)
|
|
assert "-555.00 EUR" in amounts
|
|
assert "38.00 EUR" in amounts
|
|
|
|
|
|
def test_net_settlement_unbalanced_partial_payment_raises():
|
|
# Payment of 300 can't clear a 555 receivable net of 38 payable —
|
|
# this is the pre-fix partial-payment shape that shipped unbalanced
|
|
# postings to the ledger.
|
|
with pytest.raises(ValueError, match="unbalanced"):
|
|
_net_settlement(net_fiat_amount=Decimal("300.00"))
|
|
|
|
|
|
def test_net_settlement_negative_amount_raises():
|
|
with pytest.raises(ValueError, match="non-negative"):
|
|
_net_settlement(total_receivable_fiat=Decimal("-1.00"))
|
|
|
|
|
|
def test_net_settlement_credit_overflow_adds_leg():
|
|
entry = _net_settlement(
|
|
net_fiat_amount=Decimal("600.00"),
|
|
credit_account="Liabilities:Credit:User-abc12345",
|
|
credit_overflow_fiat=Decimal("83.00"),
|
|
)
|
|
amounts = [p["amount"] for p in entry["postings"]]
|
|
assert "-83.00 EUR" in amounts
|
|
|
|
|
|
def test_net_settlement_credit_overflow_without_account_raises():
|
|
with pytest.raises(ValueError, match="credit_account"):
|
|
_net_settlement(
|
|
net_fiat_amount=Decimal("600.00"),
|
|
credit_overflow_fiat=Decimal("83.00"),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# format_posting_at_average_cost — no empty cost braces
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_average_cost_posting_without_currency_omits_braces():
|
|
posting = bf.format_posting_at_average_cost(
|
|
account="Assets:Receivable:User-abc", amount_sats=-996896,
|
|
)
|
|
assert posting["amount"] == "-996896 SATS"
|
|
assert "{" not in posting["amount"]
|
|
|
|
|
|
def test_average_cost_posting_with_currency_keeps_braces():
|
|
posting = bf.format_posting_at_average_cost(
|
|
account="Assets:Receivable:User-abc",
|
|
amount_sats=-996896,
|
|
cost_currency="EUR",
|
|
)
|
|
assert posting["amount"] == "-996896 SATS {EUR}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fiat_rate_metadata — Decimal-exact rate strings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_fiat_rate_metadata_is_exact():
|
|
meta = bf.fiat_rate_metadata(107419, Decimal("100.00"))
|
|
assert meta["fiat_rate"] == "1074.190000"
|
|
assert meta["btc_rate"] == "93093.40"
|
|
# No float artifacts like 1074.1899999999998
|
|
Decimal(meta["fiat_rate"])
|
|
Decimal(meta["btc_rate"])
|
|
|
|
|
|
def test_fiat_rate_metadata_zero_amounts():
|
|
assert bf.fiat_rate_metadata(0, Decimal("100.00")) == {
|
|
"fiat_rate": "0", "btc_rate": "0",
|
|
}
|
|
assert bf.fiat_rate_metadata(1000, Decimal("0")) == {
|
|
"fiat_rate": "0", "btc_rate": "0",
|
|
}
|