Settlement balance guards, per-currency netting, Decimal rates #57
7 changed files with 276 additions and 68 deletions
fix(settlement): per-currency netting, balance guards, Decimal rates
Settlement correctness cluster from CODE-REVIEW-2026-06 (#2, #8, #13) plus libra-#38: - format_net_settlement_entry now enforces the same inline balance constraint as the fiat formatter (payment = receivable - payable + credit) and grows an optional credit leg. An unbalanced settlement raises instead of reaching the ledger. - on_invoice_paid settles only what the payment covers: a partial payment clears that much receivable; excess (or a payment with nothing owed) becomes user credit. Previously the full prior balance was cleared against a smaller payment, shipping unbalanced postings. Settlement links are attached only when the payment clears the full open balance, and only for same-currency entries. - get_unsettled_entries_bql returns each entry's real posting currency (was hardcoded "EUR") and exact Decimal amount strings (was float). /receivables/settle nets only entries denominated in the settlement currency. - fiat_rate/btc_rate metadata computed via Decimal (new fiat_rate_metadata helper) instead of float division — cost-basis records no longer carry float drift. - format_posting_at_average_cost omits the cost braces when cost_currency is unset ("SATS {}" is invalid Beancount). - Underpay error payload serializes amounts as exact Decimal strings. - validate_metadata catches decimal.InvalidOperation so bad fiat_amount input becomes ValidationError (libra-#38); flipped the tracking xfail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
commit
cf1a0967bf
|
|
@ -252,9 +252,10 @@ def format_posting_at_average_cost(
|
||||||
amount_str = f"{amount_sats} SATS {{{cost_currency}}}"
|
amount_str = f"{amount_sats} SATS {{{cost_currency}}}"
|
||||||
logger.info(f"format_posting_at_average_cost: Generated amount_str='{amount_str}' with cost_currency='{cost_currency}'")
|
logger.info(f"format_posting_at_average_cost: Generated amount_str='{amount_str}' with cost_currency='{cost_currency}'")
|
||||||
else:
|
else:
|
||||||
# No cost
|
# No cost basis — omit the braces entirely. Empty "{}" is not
|
||||||
amount_str = f"{amount_sats} SATS {{}}"
|
# valid Beancount syntax and fails to parse on ledger load.
|
||||||
logger.warning(f"format_posting_at_average_cost: cost_currency is None, using empty cost basis")
|
amount_str = f"{amount_sats} SATS"
|
||||||
|
logger.warning(f"format_posting_at_average_cost: cost_currency is None, omitting cost basis")
|
||||||
|
|
||||||
posting_meta = metadata or {}
|
posting_meta = metadata or {}
|
||||||
|
|
||||||
|
|
@ -304,6 +305,25 @@ def format_posting_simple(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fiat_rate_metadata(amount_sats: int, fiat_amount: Decimal) -> Dict[str, str]:
|
||||||
|
"""Exchange-rate metadata (sats per fiat unit, fiat per BTC) as exact
|
||||||
|
Decimal strings.
|
||||||
|
|
||||||
|
These values become the cost-basis record for the entry, so they must
|
||||||
|
not carry float drift — CLAUDE.md mandates Decimal for all fiat math.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"fiat_rate": "<sats per fiat unit>", "btc_rate": "<fiat per BTC>"}
|
||||||
|
"""
|
||||||
|
if amount_sats <= 0 or fiat_amount <= 0:
|
||||||
|
return {"fiat_rate": "0", "btc_rate": "0"}
|
||||||
|
fiat_rate = (Decimal(amount_sats) / fiat_amount).quantize(Decimal("0.000001"))
|
||||||
|
btc_rate = (
|
||||||
|
fiat_amount / Decimal(amount_sats) * Decimal(100_000_000)
|
||||||
|
).quantize(Decimal("0.01"))
|
||||||
|
return {"fiat_rate": str(fiat_rate), "btc_rate": str(btc_rate)}
|
||||||
|
|
||||||
|
|
||||||
def format_expense_entry(
|
def format_expense_entry(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
expense_account: str,
|
expense_account: str,
|
||||||
|
|
@ -718,15 +738,18 @@ def format_net_settlement_entry(
|
||||||
entry_date: date,
|
entry_date: date,
|
||||||
payment_hash: Optional[str] = None,
|
payment_hash: Optional[str] = None,
|
||||||
reference: Optional[str] = None,
|
reference: Optional[str] = None,
|
||||||
settled_entry_links: Optional[List[str]] = None
|
settled_entry_links: Optional[List[str]] = None,
|
||||||
|
credit_account: Optional[str] = None,
|
||||||
|
credit_overflow_fiat: Decimal = Decimal(0),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Format a net settlement payment entry (user paying net balance).
|
Format a net settlement payment entry (user paying net balance).
|
||||||
|
|
||||||
Creates a three-posting transaction:
|
Creates a three- to four-posting transaction:
|
||||||
1. Lightning payment in SATS with @@ total price notation
|
1. Lightning payment in SATS with @@ total price notation
|
||||||
2. Clear receivables in EUR
|
2. Clear receivables in EUR
|
||||||
3. Clear payables in EUR
|
3. Clear payables in EUR
|
||||||
|
4. Credit overflow when the payment exceeds what it clears
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR
|
Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR
|
||||||
|
|
@ -734,25 +757,61 @@ def format_net_settlement_entry(
|
||||||
Liabilities:Payable:User 38.00 EUR
|
Liabilities:Payable:User 38.00 EUR
|
||||||
= 517 - 555 + 38 = 0 ✓
|
= 517 - 555 + 38 = 0 ✓
|
||||||
|
|
||||||
|
Constraint enforced inline (same contract as
|
||||||
|
`format_fiat_net_settlement_entry`):
|
||||||
|
net_fiat_amount = total_receivable_fiat - total_payable_fiat
|
||||||
|
+ credit_overflow_fiat
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: User ID
|
user_id: User ID
|
||||||
payment_account: Payment account (e.g., "Assets:Bitcoin:Lightning")
|
payment_account: Payment account (e.g., "Assets:Bitcoin:Lightning")
|
||||||
receivable_account: User's receivable account
|
receivable_account: User's receivable account
|
||||||
payable_account: User's payable account
|
payable_account: User's payable account
|
||||||
amount_sats: SATS amount paid
|
amount_sats: SATS amount paid
|
||||||
net_fiat_amount: Net fiat amount (receivable - payable)
|
net_fiat_amount: Fiat value of the payment being recorded
|
||||||
total_receivable_fiat: Total receivables to clear
|
total_receivable_fiat: Receivables cleared by this payment
|
||||||
total_payable_fiat: Total payables to clear
|
total_payable_fiat: Payables cleared by this payment
|
||||||
fiat_currency: Currency (EUR, USD)
|
fiat_currency: Currency (EUR, USD)
|
||||||
description: Payment description
|
description: Payment description
|
||||||
entry_date: Date of payment
|
entry_date: Date of payment
|
||||||
payment_hash: Lightning payment hash
|
payment_hash: Lightning payment hash
|
||||||
reference: Optional reference
|
reference: Optional reference
|
||||||
settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "rcv-def456"])
|
settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "rcv-def456"])
|
||||||
|
credit_account: User's credit account receiving overflow (required
|
||||||
|
when credit_overflow_fiat > 0)
|
||||||
|
credit_overflow_fiat: Payment excess beyond what it clears, absorbed
|
||||||
|
as a liability libra owes the user going forward
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Fava API entry dict
|
Fava API entry dict
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if any amount is negative, or the payment doesn't
|
||||||
|
balance against what it clears — an unbalanced settlement
|
||||||
|
must never reach the ledger.
|
||||||
"""
|
"""
|
||||||
|
for label, value in (
|
||||||
|
("net_fiat_amount", net_fiat_amount),
|
||||||
|
("total_receivable_fiat", total_receivable_fiat),
|
||||||
|
("total_payable_fiat", total_payable_fiat),
|
||||||
|
("credit_overflow_fiat", credit_overflow_fiat),
|
||||||
|
):
|
||||||
|
if value < 0:
|
||||||
|
raise ValueError(f"{label} must be non-negative; got {value}")
|
||||||
|
|
||||||
|
expected_payment = (
|
||||||
|
total_receivable_fiat - total_payable_fiat + credit_overflow_fiat
|
||||||
|
)
|
||||||
|
if abs(net_fiat_amount - expected_payment) > Decimal("0.01"):
|
||||||
|
raise ValueError(
|
||||||
|
f"net_fiat_amount {net_fiat_amount} does not match expected "
|
||||||
|
f"{expected_payment} (= receivable {total_receivable_fiat} "
|
||||||
|
f"- payable {total_payable_fiat} + credit {credit_overflow_fiat}); "
|
||||||
|
f"refusing to write an unbalanced settlement"
|
||||||
|
)
|
||||||
|
if credit_overflow_fiat > 0 and not credit_account:
|
||||||
|
raise ValueError("credit_account required when credit_overflow_fiat > 0")
|
||||||
|
|
||||||
# Build postings for net settlement
|
# Build postings for net settlement
|
||||||
# Note: We use @@ (total price) syntax for cleaner formatting, but Fava's API
|
# Note: We use @@ (total price) syntax for cleaner formatting, but Fava's API
|
||||||
# will convert this to @ (per-unit price) with a long decimal when writing to file.
|
# will convert this to @ (per-unit price) with a long decimal when writing to file.
|
||||||
|
|
@ -761,20 +820,26 @@ def format_net_settlement_entry(
|
||||||
postings = [
|
postings = [
|
||||||
{
|
{
|
||||||
"account": payment_account,
|
"account": payment_account,
|
||||||
"amount": f"{abs(amount_sats)} SATS @@ {abs(net_fiat_amount):.2f} {fiat_currency}",
|
"amount": f"{abs(amount_sats)} SATS @@ {net_fiat_amount:.2f} {fiat_currency}",
|
||||||
"meta": {"payment-hash": payment_hash} if payment_hash else {}
|
"meta": {"payment-hash": payment_hash} if payment_hash else {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"account": receivable_account,
|
"account": receivable_account,
|
||||||
"amount": f"-{abs(total_receivable_fiat):.2f} {fiat_currency}",
|
"amount": f"-{total_receivable_fiat:.2f} {fiat_currency}",
|
||||||
"meta": {"sats-equivalent": str(abs(amount_sats))}
|
"meta": {"sats-equivalent": str(abs(amount_sats))}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"account": payable_account,
|
"account": payable_account,
|
||||||
"amount": f"{abs(total_payable_fiat):.2f} {fiat_currency}",
|
"amount": f"{total_payable_fiat:.2f} {fiat_currency}",
|
||||||
"meta": {}
|
"meta": {}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
if credit_overflow_fiat > 0:
|
||||||
|
postings.append({
|
||||||
|
"account": credit_account,
|
||||||
|
"amount": f"-{credit_overflow_fiat:.2f} {fiat_currency}",
|
||||||
|
"meta": {}
|
||||||
|
})
|
||||||
|
|
||||||
entry_meta = {
|
entry_meta = {
|
||||||
"user-id": user_id,
|
"user-id": user_id,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Comprehensive validation following Beancount's plugin system approach,
|
||||||
but implemented as simple functions that can be called directly.
|
but implemented as simple functions that can be called directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal, InvalidOperation
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -278,11 +278,14 @@ def validate_metadata(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate fiat amount is valid Decimal
|
# Validate fiat amount is valid Decimal. InvalidOperation is what
|
||||||
|
# Decimal actually raises on garbage input ("abc") — it is not a
|
||||||
|
# ValueError subclass, so without it the raw exception leaked to
|
||||||
|
# callers (libra-#38).
|
||||||
if has_fiat_amount:
|
if has_fiat_amount:
|
||||||
try:
|
try:
|
||||||
Decimal(str(metadata["fiat_amount"]))
|
Decimal(str(metadata["fiat_amount"]))
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError, InvalidOperation) as e:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Invalid fiat_amount: {metadata['fiat_amount']}",
|
f"Invalid fiat_amount: {metadata['fiat_amount']}",
|
||||||
{"error": str(e)}
|
{"error": str(e)}
|
||||||
|
|
|
||||||
|
|
@ -1817,7 +1817,7 @@ class FavaClient:
|
||||||
# Query 1: Get all original expense/receivable entries for this user
|
# Query 1: Get all original expense/receivable entries for this user
|
||||||
# These are entries with the expense-entry or receivable-entry tag
|
# These are entries with the expense-entry or receivable-entry tag
|
||||||
original_query = f"""
|
original_query = f"""
|
||||||
SELECT date, narration, account, number, weight, links,
|
SELECT date, narration, account, number, currency, weight, links,
|
||||||
any_meta('entry-id') as entry_id
|
any_meta('entry-id') as entry_id
|
||||||
WHERE account ~ '{account_pattern}'
|
WHERE account ~ '{account_pattern}'
|
||||||
AND '{entry_tag}' IN tags
|
AND '{entry_tag}' IN tags
|
||||||
|
|
@ -1851,7 +1851,10 @@ class FavaClient:
|
||||||
entries_by_link: Dict[str, Dict[str, Any]] = {}
|
entries_by_link: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
for row in original_result["rows"]:
|
for row in original_result["rows"]:
|
||||||
date_val, narration, account, number, weight, links, entry_id = row
|
(
|
||||||
|
date_val, narration, account, number, currency,
|
||||||
|
weight, links, entry_id,
|
||||||
|
) = row
|
||||||
|
|
||||||
# Skip if no links
|
# Skip if no links
|
||||||
if not links or not isinstance(links, list):
|
if not links or not isinstance(links, list):
|
||||||
|
|
@ -1875,9 +1878,11 @@ class FavaClient:
|
||||||
if entry_link in entries_by_link:
|
if entry_link in entries_by_link:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Parse amounts
|
# Parse amounts. The posting's real currency matters: callers
|
||||||
fiat_amount = abs(float(number)) if number else 0.0
|
# net these totals per currency, and the old hardcoded "EUR"
|
||||||
fiat_currency = "EUR" # Default, could be extracted from posting
|
# let USD (or SATS-only) entries be summed as if they were EUR.
|
||||||
|
fiat_amount = str(abs(Decimal(str(number)))) if number else "0"
|
||||||
|
fiat_currency = currency or "EUR"
|
||||||
|
|
||||||
# Parse SATS from weight column
|
# Parse SATS from weight column
|
||||||
sats_amount = 0
|
sats_amount = 0
|
||||||
|
|
|
||||||
57
tasks.py
57
tasks.py
|
|
@ -279,24 +279,33 @@ async def on_invoice_paid(payment: Payment) -> None:
|
||||||
await release_payment_claim(payment.payment_hash)
|
await release_payment_claim(payment.payment_hash)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get user's current balance to determine receivables and payables
|
# Get user's current balance to determine what this payment clears
|
||||||
balance = await fava.get_user_balance(user_id)
|
balance = await fava.get_user_balance(user_id)
|
||||||
fiat_balances = balance.get("fiat_balances", {})
|
fiat_balances = balance.get("fiat_balances", {})
|
||||||
total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0))
|
total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0))
|
||||||
|
|
||||||
# Determine receivables and payables based on balance
|
# Settle only what this payment covers. The balance is already
|
||||||
# Positive balance = user owes libra (receivable)
|
# net (positive = user owes libra); a partial payment clears
|
||||||
# Negative balance = libra owes user (payable)
|
# that much receivable, and any excess — or the whole payment
|
||||||
if total_fiat_balance > 0:
|
# when nothing is owed — becomes credit libra owes the user.
|
||||||
# User owes libra
|
# (Previously partial payments cleared the FULL balance against
|
||||||
total_receivable = total_fiat_balance
|
# a smaller payment, shipping unbalanced postings.)
|
||||||
|
tolerance = Decimal("0.01")
|
||||||
|
open_receivable = (
|
||||||
|
total_fiat_balance if total_fiat_balance > 0 else Decimal(0)
|
||||||
|
)
|
||||||
|
total_receivable = min(open_receivable, fiat_amount)
|
||||||
total_payable = Decimal(0)
|
total_payable = Decimal(0)
|
||||||
else:
|
credit_overflow = fiat_amount - total_receivable
|
||||||
# Libra owes user
|
if credit_overflow < tolerance:
|
||||||
total_receivable = Decimal(0)
|
# Absorb sub-cent rounding into the receivable leg.
|
||||||
total_payable = abs(total_fiat_balance)
|
credit_overflow = Decimal(0)
|
||||||
|
total_receivable = fiat_amount
|
||||||
|
|
||||||
logger.info(f"Settlement: {fiat_amount} {fiat_currency} (Receivable: {total_receivable}, Payable: {total_payable})")
|
logger.info(
|
||||||
|
f"Settlement: {fiat_amount} {fiat_currency} "
|
||||||
|
f"(clears receivable: {total_receivable}, credit: {credit_overflow})"
|
||||||
|
)
|
||||||
|
|
||||||
# Get account names
|
# Get account names
|
||||||
user_receivable = await get_or_create_user_account(
|
user_receivable = await get_or_create_user_account(
|
||||||
|
|
@ -305,20 +314,32 @@ async def on_invoice_paid(payment: Payment) -> None:
|
||||||
user_payable = await get_or_create_user_account(
|
user_payable = await get_or_create_user_account(
|
||||||
user_id, AccountType.LIABILITY, "Accounts Payable"
|
user_id, AccountType.LIABILITY, "Accounts Payable"
|
||||||
)
|
)
|
||||||
|
user_credit = None
|
||||||
|
if credit_overflow > 0:
|
||||||
|
user_credit = await get_or_create_user_account(
|
||||||
|
user_id, AccountType.LIABILITY, "Credit"
|
||||||
|
)
|
||||||
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
|
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
|
||||||
if not lightning_account:
|
if not lightning_account:
|
||||||
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
|
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
|
||||||
await release_payment_claim(payment.payment_hash)
|
await release_payment_claim(payment.payment_hash)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Query for unsettled entries to link this settlement back to them
|
# Link the source entries this settlement reconciles — but only
|
||||||
# Net settlement can settle both expenses and receivables
|
# when the payment clears the full open balance. On a partial
|
||||||
|
# payment we can't know which entries are covered, and linking
|
||||||
|
# them would make get_unsettled_entries_bql treat them as
|
||||||
|
# settled. Only same-currency entries qualify either way.
|
||||||
settled_links = []
|
settled_links = []
|
||||||
|
if open_receivable > 0 and fiat_amount + tolerance >= open_receivable:
|
||||||
try:
|
try:
|
||||||
unsettled_expenses = await fava.get_unsettled_entries_bql(user_id, "expense")
|
unsettled_expenses = await fava.get_unsettled_entries_bql(user_id, "expense")
|
||||||
settled_links.extend([e["link"] for e in unsettled_expenses if e.get("link")])
|
|
||||||
unsettled_receivables = await fava.get_unsettled_entries_bql(user_id, "receivable")
|
unsettled_receivables = await fava.get_unsettled_entries_bql(user_id, "receivable")
|
||||||
settled_links.extend([e["link"] for e in unsettled_receivables if e.get("link")])
|
settled_links.extend(
|
||||||
|
e["link"]
|
||||||
|
for e in unsettled_expenses + unsettled_receivables
|
||||||
|
if e.get("link") and e.get("fiat_currency") == fiat_currency
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not query unsettled entries for settlement links: {e}")
|
logger.warning(f"Could not query unsettled entries for settlement links: {e}")
|
||||||
# Continue without links - settlement will still be recorded
|
# Continue without links - settlement will still be recorded
|
||||||
|
|
@ -338,7 +359,9 @@ async def on_invoice_paid(payment: Payment) -> None:
|
||||||
entry_date=datetime.now().date(),
|
entry_date=datetime.now().date(),
|
||||||
payment_hash=payment.payment_hash,
|
payment_hash=payment.payment_hash,
|
||||||
reference=payment.payment_hash,
|
reference=payment.payment_hash,
|
||||||
settled_entry_links=settled_links if settled_links else None
|
settled_entry_links=settled_links if settled_links else None,
|
||||||
|
credit_account=user_credit.name if user_credit else None,
|
||||||
|
credit_overflow_fiat=credit_overflow,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Submit to Fava using idempotent method to prevent duplicates
|
# Submit to Fava using idempotent method to prevent duplicates
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ Underpay without explicit entry-picks returns 400 with diff details so
|
||||||
the operator can either pay the exact net or specify `settled_entry_links`.
|
the operator can either pay the exact net or specify `settled_entry_links`.
|
||||||
"""
|
"""
|
||||||
import importlib
|
import importlib
|
||||||
|
from decimal import Decimal
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -268,10 +269,12 @@ async def test_underpay_without_explicit_links_returns_400(
|
||||||
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
|
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
|
||||||
payload = r.json().get("detail")
|
payload = r.json().get("detail")
|
||||||
assert isinstance(payload, dict), f"expected structured detail, got {payload!r}"
|
assert isinstance(payload, dict), f"expected structured detail, got {payload!r}"
|
||||||
assert payload.get("cash_paid") == 30.0
|
# Amounts are exact Decimal strings (not floats) so the operator can
|
||||||
assert payload.get("net_obligation") == 100.0
|
# act on them without precision loss.
|
||||||
assert payload.get("receivable_total") == 100.0
|
assert Decimal(payload.get("cash_paid")) == Decimal("30.00")
|
||||||
assert payload.get("payable_total") == 0.0
|
assert Decimal(payload.get("net_obligation")) == Decimal("100.00")
|
||||||
|
assert Decimal(payload.get("receivable_total")) == Decimal("100.00")
|
||||||
|
assert Decimal(payload.get("payable_total")) == Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -553,11 +553,6 @@ def test_validate_metadata_fiat_amount_without_currency_raises():
|
||||||
val.validate_metadata({"fiat_amount": "10.00"})
|
val.validate_metadata({"fiat_amount": "10.00"})
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(
|
|
||||||
reason="libra/issues/38 — except clause doesn't catch decimal.InvalidOperation, "
|
|
||||||
"so the raw exception leaks instead of becoming ValidationError. Flip when fixed.",
|
|
||||||
strict=True,
|
|
||||||
)
|
|
||||||
def test_validate_metadata_fiat_amount_invalid_decimal_raises():
|
def test_validate_metadata_fiat_amount_invalid_decimal_raises():
|
||||||
with pytest.raises(val.ValidationError) as exc:
|
with pytest.raises(val.ValidationError) as exc:
|
||||||
val.validate_metadata({"fiat_amount": "not-a-number", "fiat_currency": "EUR"})
|
val.validate_metadata({"fiat_amount": "not-a-number", "fiat_currency": "EUR"})
|
||||||
|
|
@ -570,3 +565,111 @@ def test_validate_metadata_both_present_passes():
|
||||||
|
|
||||||
def test_validate_metadata_neither_present_passes():
|
def test_validate_metadata_neither_present_passes():
|
||||||
val.validate_metadata({"source": "api"})
|
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",
|
||||||
|
}
|
||||||
|
|
|
||||||
38
views_api.py
38
views_api.py
|
|
@ -13,6 +13,7 @@ from lnbits.decorators import (
|
||||||
)
|
)
|
||||||
from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis
|
from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis
|
||||||
|
|
||||||
|
from .beancount_format import fiat_rate_metadata
|
||||||
from .crud import (
|
from .crud import (
|
||||||
approve_manual_payment_request,
|
approve_manual_payment_request,
|
||||||
check_balance_assertion,
|
check_balance_assertion,
|
||||||
|
|
@ -1074,8 +1075,7 @@ async def api_create_expense_entry(
|
||||||
metadata = {
|
metadata = {
|
||||||
"fiat_currency": data.currency.upper(),
|
"fiat_currency": data.currency.upper(),
|
||||||
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places
|
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places
|
||||||
"fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0,
|
**fiat_rate_metadata(amount_sats, data.amount),
|
||||||
"btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get or create expense account
|
# Get or create expense account
|
||||||
|
|
@ -1272,8 +1272,7 @@ async def api_create_income_entry(
|
||||||
metadata = {
|
metadata = {
|
||||||
"fiat_currency": fiat_currency,
|
"fiat_currency": fiat_currency,
|
||||||
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))),
|
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))),
|
||||||
"fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0,
|
**fiat_rate_metadata(amount_sats, data.amount),
|
||||||
"btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Submit to Fava
|
# Submit to Fava
|
||||||
|
|
@ -1371,8 +1370,7 @@ async def api_create_receivable_entry(
|
||||||
metadata = {
|
metadata = {
|
||||||
"fiat_currency": data.currency.upper(),
|
"fiat_currency": data.currency.upper(),
|
||||||
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places
|
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places
|
||||||
"fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0,
|
**fiat_rate_metadata(amount_sats, data.amount),
|
||||||
"btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get or create revenue account
|
# Get or create revenue account
|
||||||
|
|
@ -1760,15 +1758,10 @@ async def api_generate_payment_invoice(
|
||||||
proportion = Decimal(data.amount) / Decimal(total_sat_balance)
|
proportion = Decimal(data.amount) / Decimal(total_sat_balance)
|
||||||
invoice_fiat_amount = abs(total_fiat_balance) * proportion
|
invoice_fiat_amount = abs(total_fiat_balance) * proportion
|
||||||
|
|
||||||
# Calculate fiat rate (sats per fiat unit)
|
|
||||||
fiat_rate = float(data.amount) / float(invoice_fiat_amount) if invoice_fiat_amount > 0 else 0
|
|
||||||
btc_rate = float(invoice_fiat_amount) / float(data.amount) * 100_000_000 if data.amount > 0 else 0
|
|
||||||
|
|
||||||
invoice_extra.update({
|
invoice_extra.update({
|
||||||
"fiat_currency": fiat_currency,
|
"fiat_currency": fiat_currency,
|
||||||
"fiat_amount": str(invoice_fiat_amount.quantize(Decimal("0.001"))),
|
"fiat_amount": str(invoice_fiat_amount.quantize(Decimal("0.001"))),
|
||||||
"fiat_rate": fiat_rate,
|
**fiat_rate_metadata(data.amount, invoice_fiat_amount),
|
||||||
"btc_rate": btc_rate,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info(f"Invoice extra metadata: {invoice_extra}")
|
logger.info(f"Invoice extra metadata: {invoice_extra}")
|
||||||
|
|
@ -2064,6 +2057,19 @@ async def api_settle_receivable(
|
||||||
unsettled_payables = await fava.get_unsettled_entries_bql(data.user_id, "expense")
|
unsettled_payables = await fava.get_unsettled_entries_bql(data.user_id, "expense")
|
||||||
unsettled_receivables = await fava.get_unsettled_entries_bql(data.user_id, "receivable")
|
unsettled_receivables = await fava.get_unsettled_entries_bql(data.user_id, "receivable")
|
||||||
|
|
||||||
|
# Net only entries denominated in the settlement currency — summing
|
||||||
|
# mixed currencies as if they were one silently mis-states the net
|
||||||
|
# obligation and links entries this settlement doesn't actually clear.
|
||||||
|
settle_currency = data.currency.upper()
|
||||||
|
unsettled_payables = [
|
||||||
|
e for e in unsettled_payables
|
||||||
|
if e.get("fiat_currency") == settle_currency
|
||||||
|
]
|
||||||
|
unsettled_receivables = [
|
||||||
|
e for e in unsettled_receivables
|
||||||
|
if e.get("fiat_currency") == settle_currency
|
||||||
|
]
|
||||||
|
|
||||||
payable_total = sum(
|
payable_total = sum(
|
||||||
(Decimal(str(e["fiat_amount"])) for e in unsettled_payables),
|
(Decimal(str(e["fiat_amount"])) for e in unsettled_payables),
|
||||||
Decimal(0),
|
Decimal(0),
|
||||||
|
|
@ -2107,10 +2113,10 @@ async def api_settle_receivable(
|
||||||
"net to clear all open entries, or pass "
|
"net to clear all open entries, or pass "
|
||||||
"`settled_entry_links` to settle a specific subset."
|
"`settled_entry_links` to settle a specific subset."
|
||||||
),
|
),
|
||||||
"cash_paid": float(cash_paid),
|
"cash_paid": str(cash_paid),
|
||||||
"net_obligation": float(net_obligation),
|
"net_obligation": str(net_obligation),
|
||||||
"receivable_total": float(receivable_total),
|
"receivable_total": str(receivable_total),
|
||||||
"payable_total": float(payable_total),
|
"payable_total": str(payable_total),
|
||||||
"currency": data.currency.upper(),
|
"currency": data.currency.upper(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue