Compare commits

..

No commits in common. "3504bf2b021c2b6b4f93a404e59b49b3fb3d6289" and "36e16434708a4256cecc474fcdba2c21393e3428" have entirely different histories.

12 changed files with 41 additions and 905 deletions

View file

@ -42,42 +42,6 @@ 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
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
A failed period does **not** advance `periods_done`, so the next tick

View file

@ -88,27 +88,3 @@ async def m002_payouts(db):
"CREATE INDEX payroll.idx_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,25 +55,6 @@ class Frequency(str, Enum):
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):
active = "active" # eligible for payout on every scheduler tick
paused = "paused" # keeps its schedule position, pays nothing
@ -120,19 +101,6 @@ class CreateContract(BaseModel):
# months of back-pay in one tick.
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")
def _valid_date(cls, v: str) -> str:
datetime.strptime(v, "%Y-%m-%d") # raises for anything malformed
@ -191,8 +159,6 @@ class UpdateContract(BaseModel):
total_periods: int | None = None
label: str | None = None
memo: str | None = None
pricing_mode: PricingMode | None = None
manual_rate: float | None = None
@validator("amount")
def _positive_amount(cls, v: float | None) -> float | None:
@ -273,14 +239,6 @@ class Payout(BaseModel):
payment_hash: str | None = None
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)
@property

140
rates.py
View file

@ -1,140 +0,0 @@
"""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,9 +33,7 @@ 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.
@ -189,8 +187,6 @@ 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:
@ -218,86 +214,12 @@ 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()}"
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:
async def pay_period(contract: Contract, index: int, payday: date) -> 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
@ -318,23 +240,11 @@ async def pay_period(
"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=price.amount,
currency=price.currency,
amount=contract.amount,
currency=contract.currency,
memo=memo,
internal=True,
extra=extra,
@ -352,13 +262,6 @@ async def pay_period(
# 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(
@ -367,8 +270,6 @@ async def pay_period(
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(
@ -381,8 +282,6 @@ async def pay_period(
f"{amount_msat // 1000} sat required"
),
amount_msat=amount_msat,
rate=rate,
rate_source=price.source,
)
try:
@ -401,8 +300,6 @@ async def pay_period(
detail=f"payment failed: {exc}",
amount_msat=amount_msat,
payment_hash=invoice.payment_hash,
rate=rate,
rate_source=price.source,
)
return PeriodOutcome(
@ -411,8 +308,6 @@ async def pay_period(
status=PayoutStatus.paid,
amount_msat=amount_msat,
payment_hash=invoice.payment_hash,
rate=rate,
rate_source=price.source,
)
@ -429,7 +324,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 _today()
today = today or datetime.now(timezone.utc).date()
async with _lock_for(contract.id):
# Re-read under the lock: a manual run may have moved the position
@ -491,8 +386,6 @@ 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,
)
)
@ -629,7 +522,7 @@ def resume(
contract.status = ContractStatus.active
if not catch_up:
today = today or _today()
today = today or datetime.now(timezone.utc).date()
skipped_to = fast_forward_index(contract, today)
if skipped_to != contract.periods_done:
logger.info(
@ -658,11 +551,7 @@ def cancel(contract: Contract) -> Contract:
# ---------------------------------------------------------------------------
async def pay_now(
contract: Contract,
mode: PricingMode | None = None,
manual_rate: float | None = None,
) -> Payout:
async def pay_now(contract: Contract) -> Payout:
"""Settle the contract's next period immediately, whatever the calendar
says.
@ -676,10 +565,6 @@ async def pay_now(
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)
@ -697,9 +582,9 @@ async def pay_now(
index = contract.periods_done
payday = occurrence_on(parse_start_date(contract), contract.frequency, index)
today = _today()
today = datetime.now(timezone.utc).date()
outcome = await pay_period(contract, index, payday, mode, manual_rate)
outcome = await pay_period(contract, index, payday)
if payday > today and outcome.status == PayoutStatus.paid:
outcome.detail = f"paid early on {today.isoformat()} (due {payday})"

View file

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

View file

@ -151,18 +151,6 @@
</q-td>
</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">
<q-td :props="props">
<span :style="{opacity: 0.75, fontSize: '0.85em'}">
@ -264,22 +252,6 @@
<q-input outlined dense v-model="contractDialog.data.memo"
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"
label="Pay paydays that already passed">
<q-tooltip>
@ -352,19 +324,6 @@
label="Number of payments"></q-input>
</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.memo"
label="Payment memo"></q-input>
@ -376,38 +335,6 @@
</q-card>
</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 -->
<q-dialog v-model="scheduleDialog.show">
<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 ..models import Contract, ContractStatus, Frequency, PricingMode
from ..models import Contract, ContractStatus, Frequency
def make_contract(
@ -22,8 +22,6 @@ def make_contract(
currency: str = "sat",
backfill: bool = False,
created_at: str = "2026-01-01",
pricing_mode: PricingMode = PricingMode.payday,
manual_rate: float | None = None,
) -> Contract:
return Contract(
id=contract_id,
@ -39,8 +37,6 @@ def make_contract(
periods_done=periods_done,
status=status,
backfill=backfill,
pricing_mode=pricing_mode,
manual_rate=manual_rate,
created_at=datetime.strptime(created_at, "%Y-%m-%d").replace(
tzinfo=timezone.utc
),

View file

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

View file

@ -1,131 +0,0 @@
"""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)

View file

@ -1,241 +0,0 @@
"""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,7 +30,6 @@ from .models import (
DirectoryUser,
Payout,
PayoutStatus,
PricingMode,
SchedulePreview,
SchedulePreviewRequest,
UpdateContract,
@ -96,14 +95,6 @@ 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
@ -310,11 +301,7 @@ 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,
pricing_mode: PricingMode | None = None,
manual_rate: float | None = None,
) -> Payout:
async def api_pay_now(contract_id: str) -> 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",
@ -325,16 +312,12 @@ async def api_pay_now(
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, pricing_mode, manual_rate)
return await services.pay_now(contract)
except services.LifecycleError as exc:
raise HTTPException(HTTPStatus.CONFLICT, str(exc)) from exc