Compare commits

...

3 commits

Author SHA1 Message Date
3504bf2b02 feat(ui): choose how a late payday is priced
Surfaces the pricing modes in the console: a selector plus a rate field on
both the create and edit dialogs, and a "Rate used" column in the ledger
showing the figure alongside where it came from, so a payout can be
explained without opening the database.

Pay-now becomes a dialog rather than a confirm. That is the moment an
operator is most likely to want a rate other than the contract's own —
entering a payment that happened weeks ago at a figure they already know —
and the choice has to be made before it settles, not after. It defaults to
the contract's mode, disables the rate field unless "Rate I enter" is
picked, and refuses to submit a manual mode with no rate. The success toast
now names the rate applied, and a refused payout still shows its reason.

The mode selector is disabled for sat-denominated contracts, where there is
no conversion to have an opinion about, and its hint says the setting only
applies to a payday already in the past — otherwise it reads as though it
governs every payout, which it does not.

Quasar UMD rules honoured: no self-closing tags, `${ }` delimiters,
`:style` bindings rather than a <style> block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 21:43:21 +02:00
18a17cd693 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
2026-08-31 21:42:00 +02:00
aed31f4b70 feat: historical BTC rate lookup
Standalone piece for pricing a back-dated payout at the day it was due.
Nothing calls it yet; the pricing modes land next.

LNbits cannot answer this question. Its eight exchange providers are spot
tickers with no date parameter, and the rate history behind the admin chart
lives in TransientSettings — RAM only, wiped on restart, capped at
lnbits_exchange_history_size points, and collected for a single currency.
Measured on bohm: after 5.4h of uptime the buffer held 5h of USD at a 60
point cap, having already evicted its oldest points, with 85 fetch failures
leaving visible gaps. Fine for a monitoring graph, not for pricing a salary.

Kraken is primary and CoinGecko the fallback, decided on request count: one
OHLC?interval=1440 call returns ~720 daily candles, so a twelve-period
backfill costs a single request and every date is served from the parsed
series. CoinGecko is addressed by date, so the same backfill would be
twelve requests into free-tier rate limits — but it covers currencies
Kraken lists no pair for, and reaches back further than Kraken's ~2 years.
Kraken is also already one of the providers LNbits itself trusts.

The series is read from whichever result key is not "last": Kraken's key is
not predictable ("XXBTZEUR", "XBTUSDT", ...) and reconstructing it is a
guess. An error payload deliberately does not populate the cache, so an
unknown pair retries rather than serving an empty series for six hours.

Neither provider is allowed to raise. A rate that cannot be established
returns None, and the caller must treat that as "do not pay" — the one
outcome this module must never produce is a plausible-looking wrong number.
sats_for rounds rather than truncates, because always rounding down would
quietly shortchange the employee over the life of a contract.

Tests stub the HTTP layer rather than the function under test, so the real
cache path runs — an earlier version stubbed _kraken_series and
re-implemented the caching in the test, which would have passed even with
the cache deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 21:39:18 +02:00
12 changed files with 905 additions and 41 deletions

View file

@ -42,6 +42,42 @@ left on the employee's wallet. It was never settled and expires on its own;
that is the deliberate price of not pricing the period a second time just that is the deliberate price of not pricing the period a second time just
to run a balance check. to run a balance check.
## Pricing a back-dated period
A period whose payday is **today or ahead** is priced by LNbits itself —
`create_invoice(amount, currency)` does the conversion, and nothing here
touches the network. Every pricing mode agrees in that case.
A period whose payday is **in the past** is where the modes diverge:
| mode | converts at | use when |
|---|---|---|
| `payday` (default) | BTC's value on the payday | the expense belongs to that date |
| `current` | today's rate | the debt reads "we owe EUR 800, whenever it settles" |
| `manual` | a rate you state (`100000` EUR/BTC) | the figure was agreed, not looked up |
Set per contract; overridable per payout via `pricing_mode` / `manual_rate`
on `pay-now`, which is how you enter one back-dated payment without editing
the contract.
Historical rates come from `rates.py` — Kraken's daily OHLC series first
(one request covers every date in a backfill), CoinGecko by date as the
fallback for currencies Kraken does not quote. **Not** from LNbits' own
exchange providers: those are spot tickers with no date parameter, and the
rate history behind the admin chart is RAM-only, single-currency and wiped
on restart.
Where payroll converts, it hands `create_invoice` a **sat** amount, because
`create_invoice` always prices fiat itself and cannot be told a rate. The
rate used and its source are recorded on the payout row either way — in
`current` mode read back off the invoice LNbits priced, not recomputed.
**A rate that cannot be established fails the period.** There is deliberately
no fallback to today's rate: one that moved 30% since the payday would pay
30% off and record nothing about it. The failure goes through the same
retry-then-pause path as an underfunded wallet, and names the date and
currency it could not price.
## What a failure does ## What a failure does
A failed period does **not** advance `periods_done`, so the next tick A failed period does **not** advance `periods_done`, so the next tick

View file

@ -88,3 +88,27 @@ async def m002_payouts(db):
"CREATE INDEX payroll.idx_payouts_employee_wallet " "CREATE INDEX payroll.idx_payouts_employee_wallet "
"ON payouts (employee_wallet);" "ON payouts (employee_wallet);"
) )
async def m003_pricing_mode(db):
"""Let a back-dated period be priced at the day it was due.
Until now every payout converted at whatever the rate was when it
settled, which silently misprices a period paid late. The contract now
carries how to convert, and each payout records the rate it actually
used plus where that rate came from — so a figure in the ledger can be
explained months later instead of merely trusted.
Defaults preserve existing behaviour for anything already in flight:
every live payday is today or ahead, and for those all three modes agree
on using LNbits' own live pricing.
"""
await db.execute(
"ALTER TABLE payroll.contracts "
"ADD COLUMN pricing_mode TEXT NOT NULL DEFAULT 'payday';"
)
await db.execute("ALTER TABLE payroll.contracts ADD COLUMN manual_rate REAL;")
await db.execute("ALTER TABLE payroll.payouts ADD COLUMN rate REAL;")
await db.execute(
"ALTER TABLE payroll.payouts ADD COLUMN rate_source TEXT NOT NULL DEFAULT '';"
)

View file

@ -55,6 +55,25 @@ class Frequency(str, Enum):
yearly = "yearly" yearly = "yearly"
class PricingMode(str, Enum):
"""How a period's fiat amount becomes a sat amount.
Only ever matters for a payday in the past — for a payday that is today
or ahead, all three agree and LNbits' own live pricing is used.
"""
# Convert at what BTC was worth on the payday itself. The default: a
# back-dated period is priced as of when it was due, not when somebody
# got round to paying it.
payday = "payday"
# Convert at today's rate. Correct when the obligation is understood as
# "we owe them EUR 800, whenever it settles".
current = "current"
# Convert at a rate the operator states outright (e.g. 100000 EUR/BTC),
# for a figure that was agreed rather than looked up.
manual = "manual"
class ContractStatus(str, Enum): class ContractStatus(str, Enum):
active = "active" # eligible for payout on every scheduler tick active = "active" # eligible for payout on every scheduler tick
paused = "paused" # keeps its schedule position, pays nothing paused = "paused" # keeps its schedule position, pays nothing
@ -101,6 +120,19 @@ class CreateContract(BaseModel):
# months of back-pay in one tick. # months of back-pay in one tick.
backfill: bool = False backfill: bool = False
# How to price a back-dated period. Irrelevant for a sat-denominated
# contract, and irrelevant for any payday that is not in the past.
pricing_mode: PricingMode = PricingMode.payday
# Units of `currency` per whole BTC. Required by, and only read under,
# PricingMode.manual.
manual_rate: float | None = None
@validator("manual_rate")
def _positive_rate(cls, v: float | None) -> float | None:
if v is not None and v <= 0:
raise ValueError("manual_rate must be positive")
return v
@validator("start_date") @validator("start_date")
def _valid_date(cls, v: str) -> str: def _valid_date(cls, v: str) -> str:
datetime.strptime(v, "%Y-%m-%d") # raises for anything malformed datetime.strptime(v, "%Y-%m-%d") # raises for anything malformed
@ -159,6 +191,8 @@ class UpdateContract(BaseModel):
total_periods: int | None = None total_periods: int | None = None
label: str | None = None label: str | None = None
memo: str | None = None memo: str | None = None
pricing_mode: PricingMode | None = None
manual_rate: float | None = None
@validator("amount") @validator("amount")
def _positive_amount(cls, v: float | None) -> float | None: def _positive_amount(cls, v: float | None) -> float | None:
@ -239,6 +273,14 @@ class Payout(BaseModel):
payment_hash: str | None = None payment_hash: str | None = None
detail: str = "" detail: str = ""
# Units of `currency` per whole BTC actually used for this period, and
# which of the pricing modes produced it. For a live conversion the rate
# is read back off the invoice LNbits priced rather than recomputed, so
# the row records what happened and not an approximation of it. Null on
# a sat contract, and on a failure that never got as far as pricing.
rate: float | None = None
rate_source: str = "" # "current" | "payday" | "manual" | "" (sat / none)
created_at: datetime = Field(default_factory=_now) created_at: datetime = Field(default_factory=_now)
@property @property

140
rates.py Normal file
View file

@ -0,0 +1,140 @@
"""Historical BTC rates, for pricing a payout at the day it was due.
LNbits cannot answer this. Its eight exchange providers are spot tickers
with no date parameter, and the rate history behind the admin chart lives
in `TransientSettings` — RAM only, wiped on restart, capped at
`lnbits_exchange_history_size` points, and collected for a single currency
(`lnbits_default_accounting_currency`). Useful for a monitoring graph;
useless for "what was BTC worth on the 15th".
So payroll asks a date-aware API directly.
**Kraken first.** One `OHLC?interval=1440` request returns ~720 daily
candles, so a twelve-period backfill costs a single call and every date is
served from the parsed series. It is also already one of the providers
LNbits itself trusts.
**CoinGecko second**, for currencies Kraken lists no pair for. It is
addressed by date rather than by series, so it costs one request per date —
fine as a fallback, bad as a default against free-tier rate limits.
Both return None rather than raising. A rate that cannot be established is
a payout that must not happen: the caller records a failed period and lets
a human decide, which is the same thing an underfunded wallet does. The one
outcome this module must never produce is a plausible-looking wrong number.
"""
from datetime import date, datetime, timedelta, timezone
import httpx
from loguru import logger
# Kraken quotes bitcoin as XBT. Its response key is *not* predictable
# ("XXBTZEUR", "XBTUSDT", …), so the series is read positionally instead of
# by reconstructing the name.
_KRAKEN_URL = "https://api.kraken.com/0/public/OHLC"
_COINGECKO_URL = "https://api.coingecko.com/api/v3/coins/bitcoin/history"
_TIMEOUT = 15.0
# A daily series is only appended to once a day, so re-fetching more often
# than this buys nothing. Keeps a backfill plus every retry to one request.
_CACHE_TTL = timedelta(hours=6)
# currency -> (fetched_at, {day: close})
_series_cache: dict[str, tuple[datetime, dict[date, float]]] = {}
def _now() -> datetime:
return datetime.now(timezone.utc)
async def _kraken_series(currency: str) -> dict[date, float]:
"""Daily closes for XBT/<currency>, keyed by UTC day. Empty if the pair
is unknown or the request fails."""
cached = _series_cache.get(currency)
if cached and _now() - cached[0] < _CACHE_TTL:
return cached[1]
params = {"pair": f"XBT{currency}", "interval": "1440"}
async with httpx.AsyncClient() as client:
response = await client.get(_KRAKEN_URL, params=params, timeout=_TIMEOUT)
response.raise_for_status()
payload = response.json()
if payload.get("error"):
# Unknown pair is the ordinary case here, not an incident — it just
# means this currency needs the fallback.
logger.debug(f"payroll: kraken has no XBT{currency} series: {payload['error']}")
return {}
result = payload.get("result", {})
keys = [k for k in result if k != "last"]
if not keys:
return {}
series = {
datetime.fromtimestamp(candle[0], tz=timezone.utc).date(): float(candle[4])
for candle in result[keys[0]]
}
_series_cache[currency] = (_now(), series)
return series
async def _coingecko_rate(day: date, currency: str) -> float | None:
"""Close for one specific day. One request per date — fallback only."""
params = {"date": day.strftime("%d-%m-%Y"), "localization": "false"}
async with httpx.AsyncClient() as client:
response = await client.get(_COINGECKO_URL, params=params, timeout=_TIMEOUT)
response.raise_for_status()
payload = response.json()
price = payload.get("market_data", {}).get("current_price", {})
value = price.get(currency.lower())
return float(value) if value else None
async def historical_btc_rate(day: date, currency: str) -> float | None:
"""Units of `currency` per whole BTC on `day`, or None if unknowable.
None is a legitimate answer — the date may predate the provider's
history, the currency may not be quoted anywhere, or the API may be
down. Callers must treat it as "do not pay", never as "use some other
number".
"""
currency = currency.upper()
if currency == "SAT":
raise ValueError("sat-denominated contracts need no rate")
try:
series = await _kraken_series(currency)
if day in series:
return series[day]
if series:
logger.debug(
f"payroll: {day} outside kraken's XBT{currency} range "
f"({min(series)}..{max(series)}), trying coingecko"
)
except Exception as exc: # network, malformed payload, anything
logger.warning(f"payroll: kraken rate lookup failed for {currency}: {exc}")
try:
rate = await _coingecko_rate(day, currency)
if rate:
return rate
except Exception as exc:
logger.warning(f"payroll: coingecko rate lookup failed for {currency}: {exc}")
logger.warning(f"payroll: no historical rate for {currency} on {day}")
return None
def sats_for(amount: float, rate: float) -> int:
"""`amount` of fiat in sats, at `rate` units-per-BTC.
Rounded to the nearest sat rather than truncated: over a year of
paydays, always rounding down would quietly shortchange the employee.
"""
if rate <= 0:
raise ValueError("rate must be positive")
return round(amount / rate * 100_000_000)

View file

@ -33,7 +33,9 @@ from .models import (
Frequency, Frequency,
Payout, Payout,
PayoutStatus, PayoutStatus,
PricingMode,
) )
from .rates import historical_btc_rate, sats_for
# Frequencies that are an exact number of days: no calendar involved, so no # Frequencies that are an exact number of days: no calendar involved, so no
# clamping is possible or needed. # clamping is possible or needed.
@ -187,6 +189,8 @@ class PeriodOutcome:
detail: str = "" detail: str = ""
amount_msat: int | None = None amount_msat: int | None = None
payment_hash: str | None = None payment_hash: str | None = None
rate: float | None = None
rate_source: str = ""
@property @property
def consumed(self) -> bool: def consumed(self) -> bool:
@ -214,12 +218,86 @@ def _lock_for(contract_id: str) -> asyncio.Lock:
return lock return lock
def _today() -> date:
return datetime.now(timezone.utc).date()
def payout_memo(contract: Contract, payday: date) -> str: def payout_memo(contract: Contract, payday: date) -> str:
label = contract.memo or contract.label or "Payroll" label = contract.memo or contract.label or "Payroll"
return f"{label} — {payday.isoformat()}" 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. """Move one period's money from the source wallet to the employee wallet.
An internal invoice on the destination wallet, paid from the source 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(), "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: try:
invoice = await create_invoice( invoice = await create_invoice(
wallet_id=contract.employee_wallet, wallet_id=contract.employee_wallet,
amount=contract.amount, amount=price.amount,
currency=contract.currency, currency=price.currency,
memo=memo, memo=memo,
internal=True, internal=True,
extra=extra, 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. # invoice.amount is msat and is now the canonical figure for this period.
amount_msat = invoice.amount 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) source = await get_wallet(contract.source_wallet)
if not source: if not source:
return PeriodOutcome( return PeriodOutcome(
@ -270,6 +367,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
status=PayoutStatus.failed, status=PayoutStatus.failed,
detail="source wallet not found", detail="source wallet not found",
amount_msat=amount_msat, amount_msat=amount_msat,
rate=rate,
rate_source=price.source,
) )
if source.balance_msat < amount_msat: if source.balance_msat < amount_msat:
return PeriodOutcome( return PeriodOutcome(
@ -282,6 +381,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
f"{amount_msat // 1000} sat required" f"{amount_msat // 1000} sat required"
), ),
amount_msat=amount_msat, amount_msat=amount_msat,
rate=rate,
rate_source=price.source,
) )
try: try:
@ -300,6 +401,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
detail=f"payment failed: {exc}", detail=f"payment failed: {exc}",
amount_msat=amount_msat, amount_msat=amount_msat,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
rate=rate,
rate_source=price.source,
) )
return PeriodOutcome( return PeriodOutcome(
@ -308,6 +411,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
status=PayoutStatus.paid, status=PayoutStatus.paid,
amount_msat=amount_msat, amount_msat=amount_msat,
payment_hash=invoice.payment_hash, 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 retried on the next tick, because a failed period does not advance the
contract's position. contract's position.
""" """
today = today or datetime.now(timezone.utc).date() today = today or _today()
async with _lock_for(contract.id): async with _lock_for(contract.id):
# Re-read under the lock: a manual run may have moved the position # 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, source_wallet=contract.source_wallet,
payment_hash=outcome.payment_hash, payment_hash=outcome.payment_hash,
detail=outcome.detail, detail=outcome.detail,
rate=outcome.rate,
rate_source=outcome.rate_source,
) )
) )
@ -522,7 +629,7 @@ def resume(
contract.status = ContractStatus.active contract.status = ContractStatus.active
if not catch_up: if not catch_up:
today = today or datetime.now(timezone.utc).date() today = today or _today()
skipped_to = fast_forward_index(contract, today) skipped_to = fast_forward_index(contract, today)
if skipped_to != contract.periods_done: if skipped_to != contract.periods_done:
logger.info( 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 """Settle the contract's next period immediately, whatever the calendar
says. says.
@ -565,6 +676,10 @@ async def pay_now(contract: Contract) -> Payout:
because `_settle`'s back-dated skip exists to stop a *new* contract because `_settle`'s back-dated skip exists to stop a *new* contract
firing surprise back-pay. An operator explicitly asking to pay a period firing surprise back-pay. An operator explicitly asking to pay a period
is not a surprise. 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): async with _lock_for(contract.id):
fresh = await crud.get_contract(contract.id) fresh = await crud.get_contract(contract.id)
@ -582,9 +697,9 @@ async def pay_now(contract: Contract) -> Payout:
index = contract.periods_done index = contract.periods_done
payday = occurrence_on(parse_start_date(contract), contract.frequency, index) 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: if payday > today and outcome.status == PayoutStatus.paid:
outcome.detail = f"paid early on {today.isoformat()} (due {payday})" outcome.detail = f"paid early on {today.isoformat()} (due {payday})"

View file

@ -15,6 +15,12 @@ const FREQUENCIES = [
{value: 'yearly', label: 'Yearly'} {value: 'yearly', label: 'Yearly'}
] ]
const PRICING_MODES = [
{value: 'payday', label: "Rate on the payday"},
{value: 'current', label: "Rate at settlement"},
{value: 'manual', label: 'Rate I enter'}
]
const STATUS_COLOUR = { const STATUS_COLOUR = {
active: 'positive', active: 'positive',
paused: 'warning', paused: 'warning',
@ -45,7 +51,11 @@ function emptyContract() {
total_periods: 12, total_periods: 12,
label: '', label: '',
memo: '', memo: '',
backfill: false backfill: false,
// Only ever consulted for a payday in the past; for a payday today or
// ahead every mode collapses to LNbits' own live pricing.
pricing_mode: 'payday',
manual_rate: null
} }
} }
@ -69,6 +79,14 @@ window.app = Vue.createApp({
frequencyOptions: FREQUENCIES, frequencyOptions: FREQUENCIES,
contractDialog: {show: false, data: emptyContract(), saving: false}, contractDialog: {show: false, data: emptyContract(), saving: false},
payNowDialog: {
show: false,
contract: null,
pricing_mode: 'payday',
manual_rate: null,
loading: false
},
pricingModes: PRICING_MODES,
// Live calendar of whatever is currently typed into the dialog. The // Live calendar of whatever is currently typed into the dialog. The
// cheapest moment to notice a wrong start date or frequency is before // cheapest moment to notice a wrong start date or frequency is before
// anything is saved. // anything is saved.
@ -121,6 +139,7 @@ window.app = Vue.createApp({
align: 'left', align: 'left',
field: 'contract_id' field: 'contract_id'
}, },
{name: 'rate', label: 'Rate used', align: 'right', field: 'rate'},
{name: 'attempt', label: 'Try', align: 'center', field: 'attempt'}, {name: 'attempt', label: 'Try', align: 'center', field: 'attempt'},
{name: 'detail', label: 'Detail', align: 'left', field: 'detail'} {name: 'detail', label: 'Detail', align: 'left', field: 'detail'}
] ]
@ -331,7 +350,9 @@ window.app = Vue.createApp({
total_periods: d.open_ended ? null : Number(d.total_periods), total_periods: d.open_ended ? null : Number(d.total_periods),
label: d.label, label: d.label,
memo: d.memo, memo: d.memo,
backfill: d.backfill backfill: d.backfill,
pricing_mode: d.pricing_mode,
manual_rate: d.pricing_mode === 'manual' ? Number(d.manual_rate) : null
}) })
this.contractDialog.show = false this.contractDialog.show = false
Quasar.Notify.create({type: 'positive', message: 'Payroll line created'}) Quasar.Notify.create({type: 'positive', message: 'Payroll line created'})
@ -354,7 +375,9 @@ window.app = Vue.createApp({
open_ended: contract.total_periods === null, open_ended: contract.total_periods === null,
total_periods: contract.total_periods, total_periods: contract.total_periods,
label: contract.label, label: contract.label,
memo: contract.memo memo: contract.memo,
pricing_mode: contract.pricing_mode,
manual_rate: contract.manual_rate
} }
} }
}, },
@ -372,7 +395,9 @@ window.app = Vue.createApp({
frequency: d.frequency, frequency: d.frequency,
total_periods: d.open_ended ? null : Number(d.total_periods), total_periods: d.open_ended ? null : Number(d.total_periods),
label: d.label, label: d.label,
memo: d.memo memo: d.memo,
pricing_mode: d.pricing_mode,
manual_rate: d.pricing_mode === 'manual' ? Number(d.manual_rate) : null
} }
) )
this.editDialog.show = false this.editDialog.show = false
@ -416,16 +441,29 @@ window.app = Vue.createApp({
}, },
payNow(contract) { payNow(contract) {
LNbits.utils // A dialog rather than a confirm: paying a back-dated period is
.confirmDialog( // exactly when the operator may want a different rate than the
`Pay the next period of "${contract.label || contract.id}" now? ` + // contract's own, and that choice has to be made before it settles.
'It is consumed even if its payday has not arrived yet.' this.payNowDialog = {
) show: true,
.onOk(async () => { contract,
pricing_mode: contract.pricing_mode || 'payday',
manual_rate: contract.manual_rate,
loading: false
}
},
async confirmPayNow() {
const d = this.payNowDialog
const params = new URLSearchParams({pricing_mode: d.pricing_mode})
if (d.pricing_mode === 'manual' && d.manual_rate) {
params.append('manual_rate', String(d.manual_rate))
}
d.loading = true
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'POST', 'POST',
`${API}/contracts/${contract.id}/pay-now` `${API}/contracts/${d.contract.id}/pay-now?${params.toString()}`
) )
// A refused payout still returns a ledger row — surfacing its // A refused payout still returns a ledger row — surfacing its
// reason is the whole point of triggering one by hand. // reason is the whole point of triggering one by hand.
@ -433,16 +471,19 @@ window.app = Vue.createApp({
type: data.status === 'paid' ? 'positive' : 'negative', type: data.status === 'paid' ? 'positive' : 'negative',
message: message:
data.status === 'paid' data.status === 'paid'
? `Paid ${Math.round(data.amount_msat / 1000)} sat` ? `Paid ${Math.round(data.amount_msat / 1000)} sat` +
(data.rate ? ` at ${data.rate.toLocaleString()} / BTC` : '')
: `Payout ${data.status}: ${data.detail}`, : `Payout ${data.status}: ${data.detail}`,
timeout: data.status === 'paid' ? 3000 : 8000 timeout: data.status === 'paid' ? 4000 : 9000
}) })
d.show = false
this.getContracts() this.getContracts()
this.getPayouts() this.getPayouts()
} catch (err) { } catch (err) {
this._err(err, 'Could not pay now') this._err(err, 'Could not pay now')
} finally {
d.loading = false
} }
})
}, },
deleteContract(contract) { deleteContract(contract) {

View file

@ -151,6 +151,18 @@
</q-td> </q-td>
</template> </template>
<template v-slot:body-cell-rate="props">
<q-td :props="props">
<span v-if="props.row.rate">
${ Math.round(props.row.rate).toLocaleString() }
<span :style="{opacity: 0.6, fontSize: '0.8em'}">
${ props.row.rate_source }
</span>
</span>
<span v-else :style="{opacity: 0.4}">—</span>
</q-td>
</template>
<template v-slot:body-cell-detail="props"> <template v-slot:body-cell-detail="props">
<q-td :props="props"> <q-td :props="props">
<span :style="{opacity: 0.75, fontSize: '0.85em'}"> <span :style="{opacity: 0.75, fontSize: '0.85em'}">
@ -252,6 +264,22 @@
<q-input outlined dense v-model="contractDialog.data.memo" <q-input outlined dense v-model="contractDialog.data.memo"
label="Payment memo" hint="What the employee sees on the payment"></q-input> label="Payment memo" hint="What the employee sees on the payment"></q-input>
<div class="row q-col-gutter-sm">
<div class="col-7">
<q-select outlined dense v-model="contractDialog.data.pricing_mode"
:options="pricingModes" emit-value map-options
:disable="contractDialog.data.currency === 'sat'"
label="Price a late payday at"
hint="Only applies to a payday already in the past"></q-select>
</div>
<div class="col-5">
<q-input outlined dense type="number" step="any"
v-model.number="contractDialog.data.manual_rate"
:disable="contractDialog.data.pricing_mode !== 'manual'"
:label="'Rate (' + contractDialog.data.currency + '/BTC)'"></q-input>
</div>
</div>
<q-toggle v-model="contractDialog.data.backfill" <q-toggle v-model="contractDialog.data.backfill"
label="Pay paydays that already passed"> label="Pay paydays that already passed">
<q-tooltip> <q-tooltip>
@ -324,6 +352,19 @@
label="Number of payments"></q-input> label="Number of payments"></q-input>
</div> </div>
</div> </div>
<div class="row q-col-gutter-sm">
<div class="col-7">
<q-select outlined dense v-model="editDialog.data.pricing_mode"
:options="pricingModes" emit-value map-options
label="Price a late payday at"></q-select>
</div>
<div class="col-5">
<q-input outlined dense type="number" step="any"
v-model.number="editDialog.data.manual_rate"
:disable="editDialog.data.pricing_mode !== 'manual'"
label="Rate per BTC"></q-input>
</div>
</div>
<q-input outlined dense v-model="editDialog.data.label" label="Label"></q-input> <q-input outlined dense v-model="editDialog.data.label" label="Label"></q-input>
<q-input outlined dense v-model="editDialog.data.memo" <q-input outlined dense v-model="editDialog.data.memo"
label="Payment memo"></q-input> label="Payment memo"></q-input>
@ -335,6 +376,38 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- ==================================================== pay now -->
<q-dialog v-model="payNowDialog.show">
<q-card style="width: 460px; max-width: 95vw">
<q-card-section>
<div class="text-h6">Pay the next period now</div>
<div v-if="payNowDialog.contract" class="text-caption"
:style="{opacity: 0.7}">
${ payNowDialog.contract.label || payNowDialog.contract.employee_username }
— ${ payNowDialog.contract.amount } ${ payNowDialog.contract.currency }.
The period is consumed even if its payday has not arrived yet.
</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<q-select outlined dense v-model="payNowDialog.pricing_mode"
:options="pricingModes" emit-value map-options
label="Price it at"
hint="Ignored unless the payday is already in the past"></q-select>
<q-input outlined dense type="number" step="any"
v-model.number="payNowDialog.manual_rate"
:disable="payNowDialog.pricing_mode !== 'manual'"
label="Rate per BTC"
hint="e.g. 100000 for 100,000 per BTC"></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary" label="Pay now" :loading="payNowDialog.loading"
:disable="payNowDialog.pricing_mode === 'manual' && !payNowDialog.manual_rate"
@click="confirmPayNow"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =================================================== schedule --> <!-- =================================================== schedule -->
<q-dialog v-model="scheduleDialog.show"> <q-dialog v-model="scheduleDialog.show">
<q-card style="width: 420px; max-width: 95vw"> <q-card style="width: 420px; max-width: 95vw">

View file

@ -7,7 +7,7 @@ keeping `services` split into a pure half and an effectful half.
from datetime import datetime, timezone from datetime import datetime, timezone
from ..models import Contract, ContractStatus, Frequency from ..models import Contract, ContractStatus, Frequency, PricingMode
def make_contract( def make_contract(
@ -22,6 +22,8 @@ def make_contract(
currency: str = "sat", currency: str = "sat",
backfill: bool = False, backfill: bool = False,
created_at: str = "2026-01-01", created_at: str = "2026-01-01",
pricing_mode: PricingMode = PricingMode.payday,
manual_rate: float | None = None,
) -> Contract: ) -> Contract:
return Contract( return Contract(
id=contract_id, id=contract_id,
@ -37,6 +39,8 @@ def make_contract(
periods_done=periods_done, periods_done=periods_done,
status=status, status=status,
backfill=backfill, backfill=backfill,
pricing_mode=pricing_mode,
manual_rate=manual_rate,
created_at=datetime.strptime(created_at, "%Y-%m-%d").replace( created_at=datetime.strptime(created_at, "%Y-%m-%d").replace(
tzinfo=timezone.utc tzinfo=timezone.utc
), ),

View file

@ -57,7 +57,7 @@ def payroll_stub(monkeypatch):
async def fake_count_period_failures(_contract_id, _period_index): async def fake_count_period_failures(_contract_id, _period_index):
return stub.prior_failures return stub.prior_failures
async def fake_pay_period(contract, index, payday): async def fake_pay_period(contract, index, payday, mode=None, manual_rate=None):
stub.attempted.append(index) stub.attempted.append(index)
paid = stub.outcome_status == PayoutStatus.paid paid = stub.outcome_status == PayoutStatus.paid
return PeriodOutcome( return PeriodOutcome(

131
tests/test_pricing.py Normal file
View file

@ -0,0 +1,131 @@
"""Pricing a period.
`resolve_price` is where a back-dated payout stops being worth today's
money. These pin down which mode wins when, and that an unavailable rate
refuses rather than substitutes.
"""
import asyncio
from datetime import date
import pytest
from .. import services
from ..models import PricingMode
from ..services import PricingError, resolve_price
from .conftest import make_contract
TODAY = date(2026, 8, 31)
PAST = date(2026, 5, 15)
PAST_RATE = 69743.26
@pytest.fixture
def historical(monkeypatch):
"""Stub the lookup and record what it was asked for."""
asked = []
async def fake_rate(day, currency):
asked.append((day, currency))
return PAST_RATE if currency == "EUR" else None
monkeypatch.setattr(services, "historical_btc_rate", fake_rate)
return asked
def eur(**kw):
return make_contract(currency="EUR", amount=800, **kw)
def price(contract, payday=PAST, today=TODAY, **kw):
return asyncio.run(resolve_price(contract, payday, today, **kw))
# --- when history is irrelevant --------------------------------------------
def test_a_payday_today_uses_live_pricing(historical):
"""No mode needs history for a payday that has not passed — LNbits' own
conversion is the freshest source there is."""
p = price(eur(), payday=TODAY)
assert (p.currency, p.source) == ("EUR", "current")
assert p.amount == 800 # handed to create_invoice as fiat, unconverted
assert historical == []
def test_a_future_payday_uses_live_pricing(historical):
p = price(eur(), payday=date(2026, 12, 1))
assert p.source == "current"
assert historical == []
def test_a_sat_contract_never_prices_at_all(historical):
p = price(make_contract(currency="sat", amount=1000))
assert (p.amount, p.currency, p.rate, p.source) == (1000, "sat", None, "")
assert historical == []
# --- the three modes -------------------------------------------------------
def test_payday_mode_converts_at_the_paydays_rate(historical):
p = price(eur(pricing_mode=PricingMode.payday))
assert p.source == "payday"
assert p.currency == "sat"
assert p.rate == PAST_RATE
assert p.amount == round(800 / PAST_RATE * 1e8)
assert historical == [(PAST, "EUR")]
def test_current_mode_ignores_the_past_payday(historical):
"""The employee is owed EUR 800 and gets EUR 800 of bitcoin today."""
p = price(eur(pricing_mode=PricingMode.current))
assert (p.currency, p.source) == ("EUR", "current")
assert historical == []
def test_manual_mode_uses_the_stated_rate(historical):
p = price(eur(pricing_mode=PricingMode.manual, manual_rate=100_000))
assert p.source == "manual"
assert p.rate == 100_000
assert p.amount == 800_000 # 800 EUR at 100k EUR/BTC == 0.008 BTC
assert historical == []
def test_manual_mode_without_a_rate_refuses(historical):
with pytest.raises(PricingError):
price(eur(pricing_mode=PricingMode.manual))
# --- per-call override -----------------------------------------------------
def test_an_override_beats_the_contract(historical):
"""Pricing one off-cycle payout differently must not require editing the
contract."""
contract = eur(pricing_mode=PricingMode.payday)
p = price(contract, mode=PricingMode.manual, manual_rate=50_000)
assert (p.source, p.rate) == ("manual", 50_000)
assert historical == []
def test_an_override_rate_supplies_a_contract_that_has_none(historical):
p = price(eur(), mode=PricingMode.manual, manual_rate=50_000)
assert p.amount == round(800 / 50_000 * 1e8)
# --- the refusal that matters ----------------------------------------------
def test_an_unavailable_rate_refuses_rather_than_substituting(historical):
"""The whole point: no silent fallback to today's rate. A rate that
moved 30% since the payday would otherwise pay 30% off, discovered only
on review."""
with pytest.raises(PricingError):
price(make_contract(currency="JPY", amount=800))
def test_the_refusal_names_the_date_and_currency(historical):
with pytest.raises(PricingError) as exc:
price(make_contract(currency="JPY", amount=800))
assert "JPY" in str(exc.value) and "2026-05-15" in str(exc.value)

241
tests/test_rates.py Normal file
View file

@ -0,0 +1,241 @@
"""Historical rate lookup.
The network is stubbed — what these pin down is the fallback order, the
caching that keeps a backfill to one request, and above all that an
unknowable rate comes back as None rather than as some other number.
"""
import asyncio
from datetime import date, datetime, timedelta, timezone
import pytest
from .. import rates
from ..rates import historical_btc_rate, sats_for
@pytest.fixture(autouse=True)
def _clear_cache():
rates._series_cache.clear()
yield
rates._series_cache.clear()
class Recorder:
def __init__(self):
self.kraken_calls = 0
self.coingecko_calls = 0
@pytest.fixture
def stub(monkeypatch):
rec = Recorder()
async def fake_kraken(currency):
rec.kraken_calls += 1
if currency != "EUR":
return {} # no such pair
return {date(2026, 5, 15): 69743.26, date(2026, 5, 16): 70120.0}
async def fake_coingecko(day, currency):
rec.coingecko_calls += 1
return 61000.0 if currency == "GBP" else None
monkeypatch.setattr(rates, "_kraken_series", fake_kraken)
monkeypatch.setattr(rates, "_coingecko_rate", fake_coingecko)
return rec
# --- sourcing --------------------------------------------------------------
def test_kraken_answers_without_touching_the_fallback(stub):
rate = asyncio.run(historical_btc_rate(date(2026, 5, 15), "EUR"))
assert rate == 69743.26
assert (stub.kraken_calls, stub.coingecko_calls) == (1, 0)
def test_currency_kraken_does_not_quote_falls_through(stub):
rate = asyncio.run(historical_btc_rate(date(2026, 5, 15), "GBP"))
assert rate == 61000.0
assert stub.coingecko_calls == 1
def test_date_outside_krakens_range_falls_through(stub):
"""Kraken keeps ~2 years; an older payday has to go to CoinGecko."""
asyncio.run(historical_btc_rate(date(2019, 1, 1), "EUR"))
assert stub.coingecko_calls == 1
def test_currency_is_case_insensitive(stub):
assert asyncio.run(historical_btc_rate(date(2026, 5, 15), "eur")) == 69743.26
def test_sat_contracts_are_a_programming_error():
with pytest.raises(ValueError):
asyncio.run(historical_btc_rate(date(2026, 5, 15), "sat"))
# --- the answer that must never be wrong -----------------------------------
def test_unknowable_rate_returns_none_not_a_guess(stub):
"""Neither source has it: the caller must get None so it can refuse to
pay, rather than a plausible number from the wrong day."""
assert asyncio.run(historical_btc_rate(date(2026, 5, 15), "JPY")) is None
def test_a_failing_provider_does_not_propagate(monkeypatch, stub):
async def boom(_currency):
raise RuntimeError("kraken is down")
monkeypatch.setattr(rates, "_kraken_series", boom)
# GBP still resolves via the fallback despite the primary throwing.
assert asyncio.run(historical_btc_rate(date(2026, 5, 15), "GBP")) == 61000.0
def test_both_providers_failing_returns_none(monkeypatch, stub):
async def boom(*_args):
raise RuntimeError("offline")
monkeypatch.setattr(rates, "_kraken_series", boom)
monkeypatch.setattr(rates, "_coingecko_rate", boom)
assert asyncio.run(historical_btc_rate(date(2026, 5, 15), "EUR")) is None
# --- caching ---------------------------------------------------------------
# These stub the HTTP layer rather than `_kraken_series`, so the real cache
# path runs. Stubbing the function under test would make them pass even if
# the cache were deleted.
def _kraken_payload(days: int = 31) -> dict:
base = datetime(2026, 5, 1, tzinfo=timezone.utc)
candles = [
[int((base + timedelta(days=i)).timestamp()), "0", "0", "0", f"{70000 + i}"]
for i in range(days)
]
return {"error": [], "result": {"XXBTZEUR": candles, "last": 0}}
@pytest.fixture
def http_stub(monkeypatch):
"""Replace httpx.AsyncClient with one that counts requests."""
calls = {"n": 0}
class FakeResponse:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
pass
def json(self):
return self._payload
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return False
async def get(self, _url, params=None, timeout=None):
calls["n"] += 1
return FakeResponse(_kraken_payload())
monkeypatch.setattr(rates.httpx, "AsyncClient", FakeClient)
return calls
def test_a_backfill_costs_one_request(http_stub):
"""Twelve periods, one series fetch — the whole reason Kraken is primary
rather than the date-addressed API."""
async def run():
return [
await historical_btc_rate(date(2026, 5, 1) + timedelta(days=i), "EUR")
for i in range(12)
]
got = asyncio.run(run())
assert got == [70000.0 + i for i in range(12)]
assert http_stub["n"] == 1
def test_the_series_is_parsed_from_the_positional_key(http_stub):
"""Kraken's result key is unpredictable ("XXBTZEUR", "XBTUSDT", ...), so
it is read positionally rather than reconstructed."""
assert asyncio.run(historical_btc_rate(date(2026, 5, 3), "EUR")) == 70002.0
def test_a_stale_cache_entry_is_refetched(http_stub):
asyncio.run(historical_btc_rate(date(2026, 5, 1), "EUR"))
assert http_stub["n"] == 1
fetched_at, series = rates._series_cache["EUR"]
rates._series_cache["EUR"] = (
fetched_at - rates._CACHE_TTL - timedelta(minutes=1),
series,
)
asyncio.run(historical_btc_rate(date(2026, 5, 1), "EUR"))
assert http_stub["n"] == 2
def test_a_kraken_error_payload_is_not_cached(monkeypatch):
"""An unknown pair must not poison the cache — the next call should try
again rather than serve an empty series for six hours."""
calls = {"n": 0}
class FakeResponse:
def raise_for_status(self):
pass
def json(self):
return {"error": ["EQuery:Unknown asset pair"], "result": {}}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return False
async def get(self, _url, params=None, timeout=None):
calls["n"] += 1
return FakeResponse()
monkeypatch.setattr(rates.httpx, "AsyncClient", FakeClient)
async def no_fallback(_day, _currency):
return None
monkeypatch.setattr(rates, "_coingecko_rate", no_fallback)
asyncio.run(historical_btc_rate(date(2026, 5, 1), "XYZ"))
asyncio.run(historical_btc_rate(date(2026, 5, 1), "XYZ"))
assert calls["n"] == 2
assert "XYZ" not in rates._series_cache
# --- conversion ------------------------------------------------------------
def test_sats_for_converts_at_the_given_rate():
# 10 EUR at 69743.26 EUR/BTC
assert sats_for(10, 69743.26) == round(10 / 69743.26 * 1e8)
def test_sats_for_rounds_rather_than_truncates():
"""Truncating every payday would quietly shortchange the employee over
the life of a contract."""
assert sats_for(1, 3e8) == 0 # 0.333 sat -> 0
assert sats_for(2, 3e8) == 1 # 0.667 sat -> 1, not 0
def test_sats_for_rejects_a_nonsense_rate():
for bad in (0, -1):
with pytest.raises(ValueError):
sats_for(10, bad)

View file

@ -30,6 +30,7 @@ from .models import (
DirectoryUser, DirectoryUser,
Payout, Payout,
PayoutStatus, PayoutStatus,
PricingMode,
SchedulePreview, SchedulePreview,
SchedulePreviewRequest, SchedulePreviewRequest,
UpdateContract, UpdateContract,
@ -95,6 +96,14 @@ async def _validate_terms(data: CreateContract) -> str:
HTTPStatus.BAD_REQUEST, f"Unsupported currency '{data.currency}'." 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 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") @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. """Settle the next period immediately, whatever the calendar says.
Covers both "run it now" (do not wait for the tick) and "pay it early", 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 Recorded in the ledger like any other payout, with the early-payment
noted in its detail. Returns the ledger row, including a failed one: noted in its detail. Returns the ledger row, including a failed one:
the caller wants to know *why* a manual payout did not land. 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) contract = await crud.get_contract(contract_id)
if not contract: if not contract:
raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.") raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.")
try: try:
return await services.pay_now(contract) return await services.pay_now(contract, pricing_mode, manual_rate)
except services.LifecycleError as exc: except services.LifecycleError as exc:
raise HTTPException(HTTPStatus.CONFLICT, str(exc)) from exc raise HTTPException(HTTPStatus.CONFLICT, str(exc)) from exc