Compare commits
No commits in common. "3504bf2b021c2b6b4f93a404e59b49b3fb3d6289" and "36e16434708a4256cecc474fcdba2c21393e3428" have entirely different histories.
3504bf2b02
...
36e1643470
12 changed files with 41 additions and 905 deletions
|
|
@ -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
|
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
|
||||||
|
|
|
||||||
|
|
@ -88,27 +88,3 @@ 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 '';"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
42
models.py
42
models.py
|
|
@ -55,25 +55,6 @@ 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
|
||||||
|
|
@ -120,19 +101,6 @@ 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
|
||||||
|
|
@ -191,8 +159,6 @@ 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:
|
||||||
|
|
@ -273,14 +239,6 @@ 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
140
rates.py
|
|
@ -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)
|
|
||||||
131
services.py
131
services.py
|
|
@ -33,9 +33,7 @@ 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.
|
||||||
|
|
@ -189,8 +187,6 @@ 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:
|
||||||
|
|
@ -218,86 +214,12 @@ 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()}"
|
||||||
|
|
||||||
|
|
||||||
class PricingError(ValueError):
|
async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutcome:
|
||||||
"""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
|
||||||
|
|
@ -318,23 +240,11 @@ async def pay_period(
|
||||||
"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=price.amount,
|
amount=contract.amount,
|
||||||
currency=price.currency,
|
currency=contract.currency,
|
||||||
memo=memo,
|
memo=memo,
|
||||||
internal=True,
|
internal=True,
|
||||||
extra=extra,
|
extra=extra,
|
||||||
|
|
@ -352,13 +262,6 @@ async def pay_period(
|
||||||
# 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(
|
||||||
|
|
@ -367,8 +270,6 @@ async def pay_period(
|
||||||
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(
|
||||||
|
|
@ -381,8 +282,6 @@ async def pay_period(
|
||||||
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:
|
||||||
|
|
@ -401,8 +300,6 @@ async def pay_period(
|
||||||
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(
|
||||||
|
|
@ -411,8 +308,6 @@ async def pay_period(
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -429,7 +324,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 _today()
|
today = today or datetime.now(timezone.utc).date()
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -491,8 +386,6 @@ 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,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -629,7 +522,7 @@ def resume(
|
||||||
|
|
||||||
contract.status = ContractStatus.active
|
contract.status = ContractStatus.active
|
||||||
if not catch_up:
|
if not catch_up:
|
||||||
today = today or _today()
|
today = today or datetime.now(timezone.utc).date()
|
||||||
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(
|
||||||
|
|
@ -658,11 +551,7 @@ def cancel(contract: Contract) -> Contract:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def pay_now(
|
async def pay_now(contract: Contract) -> Payout:
|
||||||
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.
|
||||||
|
|
||||||
|
|
@ -676,10 +565,6 @@ async def pay_now(
|
||||||
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)
|
||||||
|
|
@ -697,9 +582,9 @@ async def pay_now(
|
||||||
|
|
||||||
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 = _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:
|
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})"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,6 @@ 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',
|
||||||
|
|
@ -51,11 +45,7 @@ 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,14 +69,6 @@ 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.
|
||||||
|
|
@ -139,7 +121,6 @@ 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'}
|
||||||
]
|
]
|
||||||
|
|
@ -350,9 +331,7 @@ 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'})
|
||||||
|
|
@ -375,9 +354,7 @@ 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -395,9 +372,7 @@ 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
|
||||||
|
|
@ -441,29 +416,16 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
|
|
||||||
payNow(contract) {
|
payNow(contract) {
|
||||||
// A dialog rather than a confirm: paying a back-dated period is
|
LNbits.utils
|
||||||
// exactly when the operator may want a different rate than the
|
.confirmDialog(
|
||||||
// contract's own, and that choice has to be made before it settles.
|
`Pay the next period of "${contract.label || contract.id}" now? ` +
|
||||||
this.payNowDialog = {
|
'It is consumed even if its payday has not arrived yet.'
|
||||||
show: true,
|
)
|
||||||
contract,
|
.onOk(async () => {
|
||||||
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/${d.contract.id}/pay-now?${params.toString()}`
|
`${API}/contracts/${contract.id}/pay-now`
|
||||||
)
|
)
|
||||||
// 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.
|
||||||
|
|
@ -471,19 +433,16 @@ 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' ? 4000 : 9000
|
timeout: data.status === 'paid' ? 3000 : 8000
|
||||||
})
|
})
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -151,18 +151,6 @@
|
||||||
</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'}">
|
||||||
|
|
@ -264,22 +252,6 @@
|
||||||
<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>
|
||||||
|
|
@ -352,19 +324,6 @@
|
||||||
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>
|
||||||
|
|
@ -376,38 +335,6 @@
|
||||||
</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">
|
||||||
|
|
|
||||||
|
|
@ -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, PricingMode
|
from ..models import Contract, ContractStatus, Frequency
|
||||||
|
|
||||||
|
|
||||||
def make_contract(
|
def make_contract(
|
||||||
|
|
@ -22,8 +22,6 @@ 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,
|
||||||
|
|
@ -39,8 +37,6 @@ 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
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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, mode=None, manual_rate=None):
|
async def fake_pay_period(contract, index, payday):
|
||||||
stub.attempted.append(index)
|
stub.attempted.append(index)
|
||||||
paid = stub.outcome_status == PayoutStatus.paid
|
paid = stub.outcome_status == PayoutStatus.paid
|
||||||
return PeriodOutcome(
|
return PeriodOutcome(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
21
views_api.py
21
views_api.py
|
|
@ -30,7 +30,6 @@ from .models import (
|
||||||
DirectoryUser,
|
DirectoryUser,
|
||||||
Payout,
|
Payout,
|
||||||
PayoutStatus,
|
PayoutStatus,
|
||||||
PricingMode,
|
|
||||||
SchedulePreview,
|
SchedulePreview,
|
||||||
SchedulePreviewRequest,
|
SchedulePreviewRequest,
|
||||||
UpdateContract,
|
UpdateContract,
|
||||||
|
|
@ -96,14 +95,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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")
|
@payroll_api_router.post("/api/v1/contracts/{contract_id}/pay-now")
|
||||||
async def api_pay_now(
|
async def api_pay_now(contract_id: str) -> Payout:
|
||||||
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",
|
||||||
|
|
@ -325,16 +312,12 @@ async def api_pay_now(
|
||||||
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, pricing_mode, manual_rate)
|
return await services.pay_now(contract)
|
||||||
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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue