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
21
views_api.py
21
views_api.py
|
|
@ -30,6 +30,7 @@ from .models import (
|
|||
DirectoryUser,
|
||||
Payout,
|
||||
PayoutStatus,
|
||||
PricingMode,
|
||||
SchedulePreview,
|
||||
SchedulePreviewRequest,
|
||||
UpdateContract,
|
||||
|
|
@ -95,6 +96,14 @@ async def _validate_terms(data: CreateContract) -> str:
|
|||
HTTPStatus.BAD_REQUEST, f"Unsupported currency '{data.currency}'."
|
||||
)
|
||||
|
||||
# Catch this now rather than as a failed payout weeks later, when the
|
||||
# first back-dated period tries to price itself and finds no rate.
|
||||
if data.pricing_mode == PricingMode.manual and not data.manual_rate:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Manual pricing needs a rate (units of the currency per BTC).",
|
||||
)
|
||||
|
||||
return employee.display_name
|
||||
|
||||
|
||||
|
|
@ -301,7 +310,11 @@ async def api_contract_schedule(contract_id: str, count: int = 12) -> SchedulePr
|
|||
|
||||
|
||||
@payroll_api_router.post("/api/v1/contracts/{contract_id}/pay-now")
|
||||
async def api_pay_now(contract_id: str) -> Payout:
|
||||
async def api_pay_now(
|
||||
contract_id: str,
|
||||
pricing_mode: PricingMode | None = None,
|
||||
manual_rate: float | None = None,
|
||||
) -> Payout:
|
||||
"""Settle the next period immediately, whatever the calendar says.
|
||||
|
||||
Covers both "run it now" (do not wait for the tick) and "pay it early",
|
||||
|
|
@ -312,12 +325,16 @@ async def api_pay_now(contract_id: str) -> Payout:
|
|||
Recorded in the ledger like any other payout, with the early-payment
|
||||
noted in its detail. Returns the ledger row, including a failed one:
|
||||
the caller wants to know *why* a manual payout did not land.
|
||||
|
||||
`pricing_mode`/`manual_rate` price this one payout differently without
|
||||
editing the contract — for entering a payment that happened weeks ago at
|
||||
a rate the operator already knows. They apply to this call only.
|
||||
"""
|
||||
contract = await crud.get_contract(contract_id)
|
||||
if not contract:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.")
|
||||
try:
|
||||
return await services.pay_now(contract)
|
||||
return await services.pay_now(contract, pricing_mode, manual_rate)
|
||||
except services.LifecycleError as exc:
|
||||
raise HTTPException(HTTPStatus.CONFLICT, str(exc)) from exc
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue