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>
This commit is contained in:
parent
44e10caac7
commit
cf1a0967bf
7 changed files with 276 additions and 68 deletions
|
|
@ -252,9 +252,10 @@ def format_posting_at_average_cost(
|
|||
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}'")
|
||||
else:
|
||||
# No cost
|
||||
amount_str = f"{amount_sats} SATS {{}}"
|
||||
logger.warning(f"format_posting_at_average_cost: cost_currency is None, using empty cost basis")
|
||||
# No cost basis — omit the braces entirely. Empty "{}" is not
|
||||
# valid Beancount syntax and fails to parse on ledger load.
|
||||
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 {}
|
||||
|
||||
|
|
@ -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(
|
||||
user_id: str,
|
||||
expense_account: str,
|
||||
|
|
@ -718,15 +738,18 @@ def format_net_settlement_entry(
|
|||
entry_date: date,
|
||||
payment_hash: 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]:
|
||||
"""
|
||||
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
|
||||
2. Clear receivables in EUR
|
||||
3. Clear payables in EUR
|
||||
4. Credit overflow when the payment exceeds what it clears
|
||||
|
||||
Example:
|
||||
Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR
|
||||
|
|
@ -734,25 +757,61 @@ def format_net_settlement_entry(
|
|||
Liabilities:Payable:User 38.00 EUR
|
||||
= 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:
|
||||
user_id: User ID
|
||||
payment_account: Payment account (e.g., "Assets:Bitcoin:Lightning")
|
||||
receivable_account: User's receivable account
|
||||
payable_account: User's payable account
|
||||
amount_sats: SATS amount paid
|
||||
net_fiat_amount: Net fiat amount (receivable - payable)
|
||||
total_receivable_fiat: Total receivables to clear
|
||||
total_payable_fiat: Total payables to clear
|
||||
net_fiat_amount: Fiat value of the payment being recorded
|
||||
total_receivable_fiat: Receivables cleared by this payment
|
||||
total_payable_fiat: Payables cleared by this payment
|
||||
fiat_currency: Currency (EUR, USD)
|
||||
description: Payment description
|
||||
entry_date: Date of payment
|
||||
payment_hash: Lightning payment hash
|
||||
reference: Optional reference
|
||||
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:
|
||||
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
|
||||
# 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.
|
||||
|
|
@ -761,20 +820,26 @@ def format_net_settlement_entry(
|
|||
postings = [
|
||||
{
|
||||
"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 {}
|
||||
},
|
||||
{
|
||||
"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))}
|
||||
},
|
||||
{
|
||||
"account": payable_account,
|
||||
"amount": f"{abs(total_payable_fiat):.2f} {fiat_currency}",
|
||||
"amount": f"{total_payable_fiat:.2f} {fiat_currency}",
|
||||
"meta": {}
|
||||
}
|
||||
]
|
||||
if credit_overflow_fiat > 0:
|
||||
postings.append({
|
||||
"account": credit_account,
|
||||
"amount": f"-{credit_overflow_fiat:.2f} {fiat_currency}",
|
||||
"meta": {}
|
||||
})
|
||||
|
||||
entry_meta = {
|
||||
"user-id": user_id,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue