feat: price a back-dated period at the day it was due
Until now every payout converted at whatever the rate was when it settled, so a period paid late was silently mispriced. Contracts now carry a pricing mode, and every payout records the rate it used plus where that rate came from — a figure in the ledger can be explained months later instead of merely trusted. Three modes, per contract and overridable per payout: - `payday` (default) converts at what BTC was worth on the payday itself, via the historical lookup. - `current` converts at today's rate — correct when the obligation reads "we owe EUR 800 whenever it settles". - `manual` converts at a rate the operator states (100000 EUR/BTC), for a figure that was agreed rather than looked up. For a payday that is today or ahead, all three collapse to the same thing and none of them touches the network: LNbits' own live pricing is the freshest source available, so `resolve_price` returns the fiat amount unconverted and lets create_invoice do its job. History is consulted only where it can actually change the answer. Where payroll does convert, it must hand create_invoice a sat amount — create_invoice always prices fiat itself and cannot be told a rate. That moves the single conversion point into payroll, which is why the rate and its source are recorded on the payout. In `current` mode the rate is read back off the invoice LNbits priced (extra["btc_rate"]) rather than recomputed, so the row records the number actually applied. An unavailable rate raises PricingError and fails the period. Deliberately no fallback to today's rate: a rate that moved 30% since the payday would pay 30% off and hide it, which is the class of error nobody finds until an audit. The existing retry-then-pause machinery already handles a failed period, and the ledger row names the date and currency that could not be priced. Manual mode missing its rate is caught at contract-creation time instead, rather than surfacing as a failed payout weeks later. m003 defaults preserve behaviour for anything in flight — every live payday is today or ahead, where the modes agree. Verified by applying m003 to a copy of the running instance's database: the existing contract and its paid payout both survive and read back correctly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
This commit is contained in:
parent
aed31f4b70
commit
18a17cd693
7 changed files with 345 additions and 12 deletions
131
services.py
131
services.py
|
|
@ -33,7 +33,9 @@ from .models import (
|
|||
Frequency,
|
||||
Payout,
|
||||
PayoutStatus,
|
||||
PricingMode,
|
||||
)
|
||||
from .rates import historical_btc_rate, sats_for
|
||||
|
||||
# Frequencies that are an exact number of days: no calendar involved, so no
|
||||
# clamping is possible or needed.
|
||||
|
|
@ -187,6 +189,8 @@ class PeriodOutcome:
|
|||
detail: str = ""
|
||||
amount_msat: int | None = None
|
||||
payment_hash: str | None = None
|
||||
rate: float | None = None
|
||||
rate_source: str = ""
|
||||
|
||||
@property
|
||||
def consumed(self) -> bool:
|
||||
|
|
@ -214,12 +218,86 @@ def _lock_for(contract_id: str) -> asyncio.Lock:
|
|||
return lock
|
||||
|
||||
|
||||
def _today() -> date:
|
||||
return datetime.now(timezone.utc).date()
|
||||
|
||||
|
||||
def payout_memo(contract: Contract, payday: date) -> str:
|
||||
label = contract.memo or contract.label or "Payroll"
|
||||
return f"{label} — {payday.isoformat()}"
|
||||
|
||||
|
||||
async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutcome:
|
||||
class PricingError(ValueError):
|
||||
"""A period's sat amount could not be established.
|
||||
|
||||
Deliberately fatal for that period. The alternative — falling back to
|
||||
today's rate when the payday's rate is unavailable — pays a materially
|
||||
different amount than intended and hides it, which is exactly the class
|
||||
of error nobody finds until an audit.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Price:
|
||||
"""What to hand `create_invoice`, plus the provenance to record.
|
||||
|
||||
When `currency` is the contract's own, LNbits does the conversion and
|
||||
`rate` is filled in afterwards from the invoice it priced — read back
|
||||
rather than recomputed, so the ledger records what actually happened.
|
||||
"""
|
||||
|
||||
amount: float
|
||||
currency: str
|
||||
rate: float | None
|
||||
source: str
|
||||
|
||||
|
||||
async def resolve_price(
|
||||
contract: Contract,
|
||||
payday: date,
|
||||
today: date,
|
||||
mode: PricingMode | None = None,
|
||||
manual_rate: float | None = None,
|
||||
) -> Price:
|
||||
"""Decide what a period is worth, and say where the number came from.
|
||||
|
||||
`mode`/`manual_rate` override the contract's own settings for a single
|
||||
call, which is how an operator prices one off-cycle payout differently
|
||||
without editing the contract.
|
||||
"""
|
||||
if contract.currency.lower() == "sat":
|
||||
return Price(contract.amount, "sat", None, "")
|
||||
|
||||
mode = mode or contract.pricing_mode
|
||||
manual_rate = manual_rate or contract.manual_rate
|
||||
|
||||
# A payday that is today or ahead needs no history: every mode agrees,
|
||||
# and LNbits' own live pricing is the freshest source available.
|
||||
if payday >= today or mode == PricingMode.current:
|
||||
return Price(contract.amount, contract.currency, None, "current")
|
||||
|
||||
if mode == PricingMode.manual:
|
||||
if not manual_rate:
|
||||
raise PricingError("manual pricing selected but no rate given")
|
||||
return Price(
|
||||
sats_for(contract.amount, manual_rate), "sat", manual_rate, "manual"
|
||||
)
|
||||
|
||||
rate = await historical_btc_rate(payday, contract.currency)
|
||||
if rate is None:
|
||||
raise PricingError(
|
||||
f"no historical {contract.currency} rate available for {payday}"
|
||||
)
|
||||
return Price(sats_for(contract.amount, rate), "sat", rate, "payday")
|
||||
|
||||
|
||||
async def pay_period(
|
||||
contract: Contract,
|
||||
index: int,
|
||||
payday: date,
|
||||
mode: PricingMode | None = None,
|
||||
manual_rate: float | None = None,
|
||||
) -> PeriodOutcome:
|
||||
"""Move one period's money from the source wallet to the employee wallet.
|
||||
|
||||
An internal invoice on the destination wallet, paid from the source
|
||||
|
|
@ -240,11 +318,23 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
|
|||
"payday": payday.isoformat(),
|
||||
}
|
||||
|
||||
try:
|
||||
price = await resolve_price(contract, payday, _today(), mode, manual_rate)
|
||||
except (PricingError, ValueError) as exc:
|
||||
return PeriodOutcome(
|
||||
index=index,
|
||||
payday=payday,
|
||||
status=PayoutStatus.failed,
|
||||
detail=str(exc),
|
||||
)
|
||||
if price.source:
|
||||
extra["rate_source"] = price.source
|
||||
|
||||
try:
|
||||
invoice = await create_invoice(
|
||||
wallet_id=contract.employee_wallet,
|
||||
amount=contract.amount,
|
||||
currency=contract.currency,
|
||||
amount=price.amount,
|
||||
currency=price.currency,
|
||||
memo=memo,
|
||||
internal=True,
|
||||
extra=extra,
|
||||
|
|
@ -262,6 +352,13 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
|
|||
# invoice.amount is msat and is now the canonical figure for this period.
|
||||
amount_msat = invoice.amount
|
||||
|
||||
# For a live conversion LNbits stashes the rate it used on the payment;
|
||||
# read it back rather than recomputing, so the ledger records the number
|
||||
# that was actually applied.
|
||||
rate = price.rate
|
||||
if rate is None and price.source == "current":
|
||||
rate = (invoice.extra or {}).get("btc_rate")
|
||||
|
||||
source = await get_wallet(contract.source_wallet)
|
||||
if not source:
|
||||
return PeriodOutcome(
|
||||
|
|
@ -270,6 +367,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
|
|||
status=PayoutStatus.failed,
|
||||
detail="source wallet not found",
|
||||
amount_msat=amount_msat,
|
||||
rate=rate,
|
||||
rate_source=price.source,
|
||||
)
|
||||
if source.balance_msat < amount_msat:
|
||||
return PeriodOutcome(
|
||||
|
|
@ -282,6 +381,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
|
|||
f"{amount_msat // 1000} sat required"
|
||||
),
|
||||
amount_msat=amount_msat,
|
||||
rate=rate,
|
||||
rate_source=price.source,
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -300,6 +401,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
|
|||
detail=f"payment failed: {exc}",
|
||||
amount_msat=amount_msat,
|
||||
payment_hash=invoice.payment_hash,
|
||||
rate=rate,
|
||||
rate_source=price.source,
|
||||
)
|
||||
|
||||
return PeriodOutcome(
|
||||
|
|
@ -308,6 +411,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
|
|||
status=PayoutStatus.paid,
|
||||
amount_msat=amount_msat,
|
||||
payment_hash=invoice.payment_hash,
|
||||
rate=rate,
|
||||
rate_source=price.source,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -324,7 +429,7 @@ async def run_due_periods(
|
|||
retried on the next tick, because a failed period does not advance the
|
||||
contract's position.
|
||||
"""
|
||||
today = today or datetime.now(timezone.utc).date()
|
||||
today = today or _today()
|
||||
|
||||
async with _lock_for(contract.id):
|
||||
# Re-read under the lock: a manual run may have moved the position
|
||||
|
|
@ -386,6 +491,8 @@ async def _record(contract: Contract, outcome: PeriodOutcome) -> Payout:
|
|||
source_wallet=contract.source_wallet,
|
||||
payment_hash=outcome.payment_hash,
|
||||
detail=outcome.detail,
|
||||
rate=outcome.rate,
|
||||
rate_source=outcome.rate_source,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -522,7 +629,7 @@ def resume(
|
|||
|
||||
contract.status = ContractStatus.active
|
||||
if not catch_up:
|
||||
today = today or datetime.now(timezone.utc).date()
|
||||
today = today or _today()
|
||||
skipped_to = fast_forward_index(contract, today)
|
||||
if skipped_to != contract.periods_done:
|
||||
logger.info(
|
||||
|
|
@ -551,7 +658,11 @@ def cancel(contract: Contract) -> Contract:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def pay_now(contract: Contract) -> Payout:
|
||||
async def pay_now(
|
||||
contract: Contract,
|
||||
mode: PricingMode | None = None,
|
||||
manual_rate: float | None = None,
|
||||
) -> Payout:
|
||||
"""Settle the contract's next period immediately, whatever the calendar
|
||||
says.
|
||||
|
||||
|
|
@ -565,6 +676,10 @@ async def pay_now(contract: Contract) -> Payout:
|
|||
because `_settle`'s back-dated skip exists to stop a *new* contract
|
||||
firing surprise back-pay. An operator explicitly asking to pay a period
|
||||
is not a surprise.
|
||||
|
||||
`mode`/`manual_rate` price this one payout differently without editing
|
||||
the contract — the case an operator hits when entering a payment that
|
||||
happened weeks ago at a rate they already know.
|
||||
"""
|
||||
async with _lock_for(contract.id):
|
||||
fresh = await crud.get_contract(contract.id)
|
||||
|
|
@ -582,9 +697,9 @@ async def pay_now(contract: Contract) -> Payout:
|
|||
|
||||
index = contract.periods_done
|
||||
payday = occurrence_on(parse_start_date(contract), contract.frequency, index)
|
||||
today = datetime.now(timezone.utc).date()
|
||||
today = _today()
|
||||
|
||||
outcome = await pay_period(contract, index, payday)
|
||||
outcome = await pay_period(contract, index, payday, mode, manual_rate)
|
||||
if payday > today and outcome.status == PayoutStatus.paid:
|
||||
outcome.detail = f"paid early on {today.isoformat()} (due {payday})"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue