From aed31f4b704a73ee33e20f776fa4f66c4cbc05a0 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 21:39:18 +0200 Subject: [PATCH 1/3] feat: historical BTC rate lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone piece for pricing a back-dated payout at the day it was due. Nothing calls it yet; the pricing modes land next. LNbits cannot answer this question. Its eight exchange providers are spot tickers with no date parameter, and the rate history behind the admin chart lives in TransientSettings — RAM only, wiped on restart, capped at lnbits_exchange_history_size points, and collected for a single currency. Measured on bohm: after 5.4h of uptime the buffer held 5h of USD at a 60 point cap, having already evicted its oldest points, with 85 fetch failures leaving visible gaps. Fine for a monitoring graph, not for pricing a salary. Kraken is primary and CoinGecko the fallback, decided on request count: one OHLC?interval=1440 call returns ~720 daily candles, so a twelve-period backfill costs a single request and every date is served from the parsed series. CoinGecko is addressed by date, so the same backfill would be twelve requests into free-tier rate limits — but it covers currencies Kraken lists no pair for, and reaches back further than Kraken's ~2 years. Kraken is also already one of the providers LNbits itself trusts. The series is read from whichever result key is not "last": Kraken's key is not predictable ("XXBTZEUR", "XBTUSDT", ...) and reconstructing it is a guess. An error payload deliberately does not populate the cache, so an unknown pair retries rather than serving an empty series for six hours. Neither provider is allowed to raise. A rate that cannot be established returns None, and the caller must treat that as "do not pay" — the one outcome this module must never produce is a plausible-looking wrong number. sats_for rounds rather than truncates, because always rounding down would quietly shortchange the employee over the life of a contract. Tests stub the HTTP layer rather than the function under test, so the real cache path runs — an earlier version stubbed _kraken_series and re-implemented the caching in the test, which would have passed even with the cache deleted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj --- rates.py | 140 +++++++++++++++++++++++++ tests/test_rates.py | 241 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 rates.py create mode 100644 tests/test_rates.py diff --git a/rates.py b/rates.py new file mode 100644 index 0000000..a0a9910 --- /dev/null +++ b/rates.py @@ -0,0 +1,140 @@ +"""Historical BTC rates, for pricing a payout at the day it was due. + +LNbits cannot answer this. Its eight exchange providers are spot tickers +with no date parameter, and the rate history behind the admin chart lives +in `TransientSettings` — RAM only, wiped on restart, capped at +`lnbits_exchange_history_size` points, and collected for a single currency +(`lnbits_default_accounting_currency`). Useful for a monitoring graph; +useless for "what was BTC worth on the 15th". + +So payroll asks a date-aware API directly. + +**Kraken first.** One `OHLC?interval=1440` request returns ~720 daily +candles, so a twelve-period backfill costs a single call and every date is +served from the parsed series. It is also already one of the providers +LNbits itself trusts. + +**CoinGecko second**, for currencies Kraken lists no pair for. It is +addressed by date rather than by series, so it costs one request per date — +fine as a fallback, bad as a default against free-tier rate limits. + +Both return None rather than raising. A rate that cannot be established is +a payout that must not happen: the caller records a failed period and lets +a human decide, which is the same thing an underfunded wallet does. The one +outcome this module must never produce is a plausible-looking wrong number. +""" + +from datetime import date, datetime, timedelta, timezone + +import httpx +from loguru import logger + +# Kraken quotes bitcoin as XBT. Its response key is *not* predictable +# ("XXBTZEUR", "XBTUSDT", …), so the series is read positionally instead of +# by reconstructing the name. +_KRAKEN_URL = "https://api.kraken.com/0/public/OHLC" +_COINGECKO_URL = "https://api.coingecko.com/api/v3/coins/bitcoin/history" + +_TIMEOUT = 15.0 + +# A daily series is only appended to once a day, so re-fetching more often +# than this buys nothing. Keeps a backfill plus every retry to one request. +_CACHE_TTL = timedelta(hours=6) + +# currency -> (fetched_at, {day: close}) +_series_cache: dict[str, tuple[datetime, dict[date, float]]] = {} + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +async def _kraken_series(currency: str) -> dict[date, float]: + """Daily closes for XBT/, 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) diff --git a/tests/test_rates.py b/tests/test_rates.py new file mode 100644 index 0000000..e17eb9a --- /dev/null +++ b/tests/test_rates.py @@ -0,0 +1,241 @@ +"""Historical rate lookup. + +The network is stubbed — what these pin down is the fallback order, the +caching that keeps a backfill to one request, and above all that an +unknowable rate comes back as None rather than as some other number. +""" + +import asyncio +from datetime import date, datetime, timedelta, timezone + +import pytest + +from .. import rates +from ..rates import historical_btc_rate, sats_for + + +@pytest.fixture(autouse=True) +def _clear_cache(): + rates._series_cache.clear() + yield + rates._series_cache.clear() + + +class Recorder: + def __init__(self): + self.kraken_calls = 0 + self.coingecko_calls = 0 + + +@pytest.fixture +def stub(monkeypatch): + rec = Recorder() + + async def fake_kraken(currency): + rec.kraken_calls += 1 + if currency != "EUR": + return {} # no such pair + return {date(2026, 5, 15): 69743.26, date(2026, 5, 16): 70120.0} + + async def fake_coingecko(day, currency): + rec.coingecko_calls += 1 + return 61000.0 if currency == "GBP" else None + + monkeypatch.setattr(rates, "_kraken_series", fake_kraken) + monkeypatch.setattr(rates, "_coingecko_rate", fake_coingecko) + return rec + + +# --- sourcing -------------------------------------------------------------- + + +def test_kraken_answers_without_touching_the_fallback(stub): + rate = asyncio.run(historical_btc_rate(date(2026, 5, 15), "EUR")) + assert rate == 69743.26 + assert (stub.kraken_calls, stub.coingecko_calls) == (1, 0) + + +def test_currency_kraken_does_not_quote_falls_through(stub): + rate = asyncio.run(historical_btc_rate(date(2026, 5, 15), "GBP")) + assert rate == 61000.0 + assert stub.coingecko_calls == 1 + + +def test_date_outside_krakens_range_falls_through(stub): + """Kraken keeps ~2 years; an older payday has to go to CoinGecko.""" + asyncio.run(historical_btc_rate(date(2019, 1, 1), "EUR")) + assert stub.coingecko_calls == 1 + + +def test_currency_is_case_insensitive(stub): + assert asyncio.run(historical_btc_rate(date(2026, 5, 15), "eur")) == 69743.26 + + +def test_sat_contracts_are_a_programming_error(): + with pytest.raises(ValueError): + asyncio.run(historical_btc_rate(date(2026, 5, 15), "sat")) + + +# --- the answer that must never be wrong ----------------------------------- + + +def test_unknowable_rate_returns_none_not_a_guess(stub): + """Neither source has it: the caller must get None so it can refuse to + pay, rather than a plausible number from the wrong day.""" + assert asyncio.run(historical_btc_rate(date(2026, 5, 15), "JPY")) is None + + +def test_a_failing_provider_does_not_propagate(monkeypatch, stub): + async def boom(_currency): + raise RuntimeError("kraken is down") + + monkeypatch.setattr(rates, "_kraken_series", boom) + # GBP still resolves via the fallback despite the primary throwing. + assert asyncio.run(historical_btc_rate(date(2026, 5, 15), "GBP")) == 61000.0 + + +def test_both_providers_failing_returns_none(monkeypatch, stub): + async def boom(*_args): + raise RuntimeError("offline") + + monkeypatch.setattr(rates, "_kraken_series", boom) + monkeypatch.setattr(rates, "_coingecko_rate", boom) + assert asyncio.run(historical_btc_rate(date(2026, 5, 15), "EUR")) is None + + +# --- caching --------------------------------------------------------------- + +# These stub the HTTP layer rather than `_kraken_series`, so the real cache +# path runs. Stubbing the function under test would make them pass even if +# the cache were deleted. + + +def _kraken_payload(days: int = 31) -> dict: + base = datetime(2026, 5, 1, tzinfo=timezone.utc) + candles = [ + [int((base + timedelta(days=i)).timestamp()), "0", "0", "0", f"{70000 + i}"] + for i in range(days) + ] + return {"error": [], "result": {"XXBTZEUR": candles, "last": 0}} + + +@pytest.fixture +def http_stub(monkeypatch): + """Replace httpx.AsyncClient with one that counts requests.""" + calls = {"n": 0} + + class FakeResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + pass + + def json(self): + return self._payload + + class FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *_exc): + return False + + async def get(self, _url, params=None, timeout=None): + calls["n"] += 1 + return FakeResponse(_kraken_payload()) + + monkeypatch.setattr(rates.httpx, "AsyncClient", FakeClient) + return calls + + +def test_a_backfill_costs_one_request(http_stub): + """Twelve periods, one series fetch — the whole reason Kraken is primary + rather than the date-addressed API.""" + + async def run(): + return [ + await historical_btc_rate(date(2026, 5, 1) + timedelta(days=i), "EUR") + for i in range(12) + ] + + got = asyncio.run(run()) + assert got == [70000.0 + i for i in range(12)] + assert http_stub["n"] == 1 + + +def test_the_series_is_parsed_from_the_positional_key(http_stub): + """Kraken's result key is unpredictable ("XXBTZEUR", "XBTUSDT", ...), so + it is read positionally rather than reconstructed.""" + assert asyncio.run(historical_btc_rate(date(2026, 5, 3), "EUR")) == 70002.0 + + +def test_a_stale_cache_entry_is_refetched(http_stub): + asyncio.run(historical_btc_rate(date(2026, 5, 1), "EUR")) + assert http_stub["n"] == 1 + + fetched_at, series = rates._series_cache["EUR"] + rates._series_cache["EUR"] = ( + fetched_at - rates._CACHE_TTL - timedelta(minutes=1), + series, + ) + + asyncio.run(historical_btc_rate(date(2026, 5, 1), "EUR")) + assert http_stub["n"] == 2 + + +def test_a_kraken_error_payload_is_not_cached(monkeypatch): + """An unknown pair must not poison the cache — the next call should try + again rather than serve an empty series for six hours.""" + calls = {"n": 0} + + class FakeResponse: + def raise_for_status(self): + pass + + def json(self): + return {"error": ["EQuery:Unknown asset pair"], "result": {}} + + class FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *_exc): + return False + + async def get(self, _url, params=None, timeout=None): + calls["n"] += 1 + return FakeResponse() + + monkeypatch.setattr(rates.httpx, "AsyncClient", FakeClient) + + async def no_fallback(_day, _currency): + return None + + monkeypatch.setattr(rates, "_coingecko_rate", no_fallback) + + asyncio.run(historical_btc_rate(date(2026, 5, 1), "XYZ")) + asyncio.run(historical_btc_rate(date(2026, 5, 1), "XYZ")) + assert calls["n"] == 2 + assert "XYZ" not in rates._series_cache + + +# --- conversion ------------------------------------------------------------ + + +def test_sats_for_converts_at_the_given_rate(): + # 10 EUR at 69743.26 EUR/BTC + assert sats_for(10, 69743.26) == round(10 / 69743.26 * 1e8) + + +def test_sats_for_rounds_rather_than_truncates(): + """Truncating every payday would quietly shortchange the employee over + the life of a contract.""" + assert sats_for(1, 3e8) == 0 # 0.333 sat -> 0 + assert sats_for(2, 3e8) == 1 # 0.667 sat -> 1, not 0 + + +def test_sats_for_rejects_a_nonsense_rate(): + for bad in (0, -1): + with pytest.raises(ValueError): + sats_for(10, bad) From 18a17cd693e28559f8075134e3fee537803714e4 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 21:42:00 +0200 Subject: [PATCH 2/3] feat: price a back-dated period at the day it was due MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now every payout converted at whatever the rate was when it settled, so a period paid late was silently mispriced. Contracts now carry a pricing mode, and every payout records the rate it used plus where that rate came from — a figure in the ledger can be explained months later instead of merely trusted. Three modes, per contract and overridable per payout: - `payday` (default) converts at what BTC was worth on the payday itself, via the historical lookup. - `current` converts at today's rate — correct when the obligation reads "we owe EUR 800 whenever it settles". - `manual` converts at a rate the operator states (100000 EUR/BTC), for a figure that was agreed rather than looked up. For a payday that is today or ahead, all three collapse to the same thing and none of them touches the network: LNbits' own live pricing is the freshest source available, so `resolve_price` returns the fiat amount unconverted and lets create_invoice do its job. History is consulted only where it can actually change the answer. Where payroll does convert, it must hand create_invoice a sat amount — create_invoice always prices fiat itself and cannot be told a rate. That moves the single conversion point into payroll, which is why the rate and its source are recorded on the payout. In `current` mode the rate is read back off the invoice LNbits priced (extra["btc_rate"]) rather than recomputed, so the row records the number actually applied. An unavailable rate raises PricingError and fails the period. Deliberately no fallback to today's rate: a rate that moved 30% since the payday would pay 30% off and hide it, which is the class of error nobody finds until an audit. The existing retry-then-pause machinery already handles a failed period, and the ledger row names the date and currency that could not be priced. Manual mode missing its rate is caught at contract-creation time instead, rather than surfacing as a failed payout weeks later. m003 defaults preserve behaviour for anything in flight — every live payday is today or ahead, where the modes agree. Verified by applying m003 to a copy of the running instance's database: the existing contract and its paid payout both survive and read back correctly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj --- migrations.py | 24 ++++++++ models.py | 42 ++++++++++++++ services.py | 131 +++++++++++++++++++++++++++++++++++++++--- tests/conftest.py | 6 +- tests/test_payout.py | 2 +- tests/test_pricing.py | 131 ++++++++++++++++++++++++++++++++++++++++++ views_api.py | 21 ++++++- 7 files changed, 345 insertions(+), 12 deletions(-) create mode 100644 tests/test_pricing.py diff --git a/migrations.py b/migrations.py index 282e1fc..b82e7d1 100644 --- a/migrations.py +++ b/migrations.py @@ -88,3 +88,27 @@ 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 '';" + ) diff --git a/models.py b/models.py index 15ccaac..524224f 100644 --- a/models.py +++ b/models.py @@ -55,6 +55,25 @@ 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 @@ -101,6 +120,19 @@ 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 @@ -159,6 +191,8 @@ 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: @@ -239,6 +273,14 @@ 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 diff --git a/services.py b/services.py index f167e8c..f1123a0 100644 --- a/services.py +++ b/services.py @@ -33,7 +33,9 @@ from .models import ( Frequency, Payout, PayoutStatus, + PricingMode, ) +from .rates import historical_btc_rate, sats_for # Frequencies that are an exact number of days: no calendar involved, so no # clamping is possible or needed. @@ -187,6 +189,8 @@ class PeriodOutcome: detail: str = "" amount_msat: int | None = None payment_hash: str | None = None + rate: float | None = None + rate_source: str = "" @property def consumed(self) -> bool: @@ -214,12 +218,86 @@ def _lock_for(contract_id: str) -> asyncio.Lock: return lock +def _today() -> date: + return datetime.now(timezone.utc).date() + + def payout_memo(contract: Contract, payday: date) -> str: label = contract.memo or contract.label or "Payroll" return f"{label} — {payday.isoformat()}" -async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutcome: +class PricingError(ValueError): + """A period's sat amount could not be established. + + Deliberately fatal for that period. The alternative — falling back to + today's rate when the payday's rate is unavailable — pays a materially + different amount than intended and hides it, which is exactly the class + of error nobody finds until an audit. + """ + + +@dataclass +class Price: + """What to hand `create_invoice`, plus the provenance to record. + + When `currency` is the contract's own, LNbits does the conversion and + `rate` is filled in afterwards from the invoice it priced — read back + rather than recomputed, so the ledger records what actually happened. + """ + + amount: float + currency: str + rate: float | None + source: str + + +async def resolve_price( + contract: Contract, + payday: date, + today: date, + mode: PricingMode | None = None, + manual_rate: float | None = None, +) -> Price: + """Decide what a period is worth, and say where the number came from. + + `mode`/`manual_rate` override the contract's own settings for a single + call, which is how an operator prices one off-cycle payout differently + without editing the contract. + """ + if contract.currency.lower() == "sat": + return Price(contract.amount, "sat", None, "") + + mode = mode or contract.pricing_mode + manual_rate = manual_rate or contract.manual_rate + + # A payday that is today or ahead needs no history: every mode agrees, + # and LNbits' own live pricing is the freshest source available. + if payday >= today or mode == PricingMode.current: + return Price(contract.amount, contract.currency, None, "current") + + if mode == PricingMode.manual: + if not manual_rate: + raise PricingError("manual pricing selected but no rate given") + return Price( + sats_for(contract.amount, manual_rate), "sat", manual_rate, "manual" + ) + + rate = await historical_btc_rate(payday, contract.currency) + if rate is None: + raise PricingError( + f"no historical {contract.currency} rate available for {payday}" + ) + return Price(sats_for(contract.amount, rate), "sat", rate, "payday") + + +async def pay_period( + contract: Contract, + index: int, + payday: date, + mode: PricingMode | None = None, + manual_rate: float | None = None, +) -> PeriodOutcome: """Move one period's money from the source wallet to the employee wallet. An internal invoice on the destination wallet, paid from the source @@ -240,11 +318,23 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc "payday": payday.isoformat(), } + try: + price = await resolve_price(contract, payday, _today(), mode, manual_rate) + except (PricingError, ValueError) as exc: + return PeriodOutcome( + index=index, + payday=payday, + status=PayoutStatus.failed, + detail=str(exc), + ) + if price.source: + extra["rate_source"] = price.source + try: invoice = await create_invoice( wallet_id=contract.employee_wallet, - amount=contract.amount, - currency=contract.currency, + amount=price.amount, + currency=price.currency, memo=memo, internal=True, extra=extra, @@ -262,6 +352,13 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc # invoice.amount is msat and is now the canonical figure for this period. amount_msat = invoice.amount + # For a live conversion LNbits stashes the rate it used on the payment; + # read it back rather than recomputing, so the ledger records the number + # that was actually applied. + rate = price.rate + if rate is None and price.source == "current": + rate = (invoice.extra or {}).get("btc_rate") + source = await get_wallet(contract.source_wallet) if not source: return PeriodOutcome( @@ -270,6 +367,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc status=PayoutStatus.failed, detail="source wallet not found", amount_msat=amount_msat, + rate=rate, + rate_source=price.source, ) if source.balance_msat < amount_msat: return PeriodOutcome( @@ -282,6 +381,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc f"{amount_msat // 1000} sat required" ), amount_msat=amount_msat, + rate=rate, + rate_source=price.source, ) try: @@ -300,6 +401,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc detail=f"payment failed: {exc}", amount_msat=amount_msat, payment_hash=invoice.payment_hash, + rate=rate, + rate_source=price.source, ) return PeriodOutcome( @@ -308,6 +411,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc status=PayoutStatus.paid, amount_msat=amount_msat, payment_hash=invoice.payment_hash, + rate=rate, + rate_source=price.source, ) @@ -324,7 +429,7 @@ async def run_due_periods( retried on the next tick, because a failed period does not advance the contract's position. """ - today = today or datetime.now(timezone.utc).date() + today = today or _today() async with _lock_for(contract.id): # Re-read under the lock: a manual run may have moved the position @@ -386,6 +491,8 @@ async def _record(contract: Contract, outcome: PeriodOutcome) -> Payout: source_wallet=contract.source_wallet, payment_hash=outcome.payment_hash, detail=outcome.detail, + rate=outcome.rate, + rate_source=outcome.rate_source, ) ) @@ -522,7 +629,7 @@ def resume( contract.status = ContractStatus.active if not catch_up: - today = today or datetime.now(timezone.utc).date() + today = today or _today() skipped_to = fast_forward_index(contract, today) if skipped_to != contract.periods_done: logger.info( @@ -551,7 +658,11 @@ def cancel(contract: Contract) -> Contract: # --------------------------------------------------------------------------- -async def pay_now(contract: Contract) -> Payout: +async def pay_now( + contract: Contract, + mode: PricingMode | None = None, + manual_rate: float | None = None, +) -> Payout: """Settle the contract's next period immediately, whatever the calendar says. @@ -565,6 +676,10 @@ async def pay_now(contract: Contract) -> Payout: because `_settle`'s back-dated skip exists to stop a *new* contract firing surprise back-pay. An operator explicitly asking to pay a period is not a surprise. + + `mode`/`manual_rate` price this one payout differently without editing + the contract — the case an operator hits when entering a payment that + happened weeks ago at a rate they already know. """ async with _lock_for(contract.id): fresh = await crud.get_contract(contract.id) @@ -582,9 +697,9 @@ async def pay_now(contract: Contract) -> Payout: index = contract.periods_done payday = occurrence_on(parse_start_date(contract), contract.frequency, index) - today = datetime.now(timezone.utc).date() + today = _today() - outcome = await pay_period(contract, index, payday) + outcome = await pay_period(contract, index, payday, mode, manual_rate) if payday > today and outcome.status == PayoutStatus.paid: outcome.detail = f"paid early on {today.isoformat()} (due {payday})" diff --git a/tests/conftest.py b/tests/conftest.py index 3bd7a28..99fc158 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 +from ..models import Contract, ContractStatus, Frequency, PricingMode def make_contract( @@ -22,6 +22,8 @@ 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, @@ -37,6 +39,8 @@ 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 ), diff --git a/tests/test_payout.py b/tests/test_payout.py index defff71..0157481 100644 --- a/tests/test_payout.py +++ b/tests/test_payout.py @@ -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): + async def fake_pay_period(contract, index, payday, mode=None, manual_rate=None): stub.attempted.append(index) paid = stub.outcome_status == PayoutStatus.paid return PeriodOutcome( diff --git a/tests/test_pricing.py b/tests/test_pricing.py new file mode 100644 index 0000000..8c5c908 --- /dev/null +++ b/tests/test_pricing.py @@ -0,0 +1,131 @@ +"""Pricing a period. + +`resolve_price` is where a back-dated payout stops being worth today's +money. These pin down which mode wins when, and that an unavailable rate +refuses rather than substitutes. +""" + +import asyncio +from datetime import date + +import pytest + +from .. import services +from ..models import PricingMode +from ..services import PricingError, resolve_price +from .conftest import make_contract + +TODAY = date(2026, 8, 31) +PAST = date(2026, 5, 15) +PAST_RATE = 69743.26 + + +@pytest.fixture +def historical(monkeypatch): + """Stub the lookup and record what it was asked for.""" + asked = [] + + async def fake_rate(day, currency): + asked.append((day, currency)) + return PAST_RATE if currency == "EUR" else None + + monkeypatch.setattr(services, "historical_btc_rate", fake_rate) + return asked + + +def eur(**kw): + return make_contract(currency="EUR", amount=800, **kw) + + +def price(contract, payday=PAST, today=TODAY, **kw): + return asyncio.run(resolve_price(contract, payday, today, **kw)) + + +# --- when history is irrelevant -------------------------------------------- + + +def test_a_payday_today_uses_live_pricing(historical): + """No mode needs history for a payday that has not passed — LNbits' own + conversion is the freshest source there is.""" + p = price(eur(), payday=TODAY) + assert (p.currency, p.source) == ("EUR", "current") + assert p.amount == 800 # handed to create_invoice as fiat, unconverted + assert historical == [] + + +def test_a_future_payday_uses_live_pricing(historical): + p = price(eur(), payday=date(2026, 12, 1)) + assert p.source == "current" + assert historical == [] + + +def test_a_sat_contract_never_prices_at_all(historical): + p = price(make_contract(currency="sat", amount=1000)) + assert (p.amount, p.currency, p.rate, p.source) == (1000, "sat", None, "") + assert historical == [] + + +# --- the three modes ------------------------------------------------------- + + +def test_payday_mode_converts_at_the_paydays_rate(historical): + p = price(eur(pricing_mode=PricingMode.payday)) + assert p.source == "payday" + assert p.currency == "sat" + assert p.rate == PAST_RATE + assert p.amount == round(800 / PAST_RATE * 1e8) + assert historical == [(PAST, "EUR")] + + +def test_current_mode_ignores_the_past_payday(historical): + """The employee is owed EUR 800 and gets EUR 800 of bitcoin today.""" + p = price(eur(pricing_mode=PricingMode.current)) + assert (p.currency, p.source) == ("EUR", "current") + assert historical == [] + + +def test_manual_mode_uses_the_stated_rate(historical): + p = price(eur(pricing_mode=PricingMode.manual, manual_rate=100_000)) + assert p.source == "manual" + assert p.rate == 100_000 + assert p.amount == 800_000 # 800 EUR at 100k EUR/BTC == 0.008 BTC + assert historical == [] + + +def test_manual_mode_without_a_rate_refuses(historical): + with pytest.raises(PricingError): + price(eur(pricing_mode=PricingMode.manual)) + + +# --- per-call override ----------------------------------------------------- + + +def test_an_override_beats_the_contract(historical): + """Pricing one off-cycle payout differently must not require editing the + contract.""" + contract = eur(pricing_mode=PricingMode.payday) + p = price(contract, mode=PricingMode.manual, manual_rate=50_000) + assert (p.source, p.rate) == ("manual", 50_000) + assert historical == [] + + +def test_an_override_rate_supplies_a_contract_that_has_none(historical): + p = price(eur(), mode=PricingMode.manual, manual_rate=50_000) + assert p.amount == round(800 / 50_000 * 1e8) + + +# --- the refusal that matters ---------------------------------------------- + + +def test_an_unavailable_rate_refuses_rather_than_substituting(historical): + """The whole point: no silent fallback to today's rate. A rate that + moved 30% since the payday would otherwise pay 30% off, discovered only + on review.""" + with pytest.raises(PricingError): + price(make_contract(currency="JPY", amount=800)) + + +def test_the_refusal_names_the_date_and_currency(historical): + with pytest.raises(PricingError) as exc: + price(make_contract(currency="JPY", amount=800)) + assert "JPY" in str(exc.value) and "2026-05-15" in str(exc.value) diff --git a/views_api.py b/views_api.py index 7218974..2d82d6d 100644 --- a/views_api.py +++ b/views_api.py @@ -30,6 +30,7 @@ from .models import ( DirectoryUser, Payout, PayoutStatus, + PricingMode, SchedulePreview, SchedulePreviewRequest, UpdateContract, @@ -95,6 +96,14 @@ async def _validate_terms(data: CreateContract) -> str: HTTPStatus.BAD_REQUEST, f"Unsupported currency '{data.currency}'." ) + # Catch this now rather than as a failed payout weeks later, when the + # first back-dated period tries to price itself and finds no rate. + if data.pricing_mode == PricingMode.manual and not data.manual_rate: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "Manual pricing needs a rate (units of the currency per BTC).", + ) + return employee.display_name @@ -301,7 +310,11 @@ async def api_contract_schedule(contract_id: str, count: int = 12) -> SchedulePr @payroll_api_router.post("/api/v1/contracts/{contract_id}/pay-now") -async def api_pay_now(contract_id: str) -> Payout: +async def api_pay_now( + contract_id: str, + pricing_mode: PricingMode | None = None, + manual_rate: float | None = None, +) -> Payout: """Settle the next period immediately, whatever the calendar says. Covers both "run it now" (do not wait for the tick) and "pay it early", @@ -312,12 +325,16 @@ async def api_pay_now(contract_id: str) -> Payout: Recorded in the ledger like any other payout, with the early-payment noted in its detail. Returns the ledger row, including a failed one: the caller wants to know *why* a manual payout did not land. + + `pricing_mode`/`manual_rate` price this one payout differently without + editing the contract — for entering a payment that happened weeks ago at + a rate the operator already knows. They apply to this call only. """ contract = await crud.get_contract(contract_id) if not contract: raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.") try: - return await services.pay_now(contract) + return await services.pay_now(contract, pricing_mode, manual_rate) except services.LifecycleError as exc: raise HTTPException(HTTPStatus.CONFLICT, str(exc)) from exc From 3504bf2b021c2b6b4f93a404e59b49b3fb3d6289 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 21:43:21 +0200 Subject: [PATCH 3/3] feat(ui): choose how a late payday is priced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the pricing modes in the console: a selector plus a rate field on both the create and edit dialogs, and a "Rate used" column in the ledger showing the figure alongside where it came from, so a payout can be explained without opening the database. Pay-now becomes a dialog rather than a confirm. That is the moment an operator is most likely to want a rate other than the contract's own — entering a payment that happened weeks ago at a figure they already know — and the choice has to be made before it settles, not after. It defaults to the contract's mode, disables the rate field unless "Rate I enter" is picked, and refuses to submit a manual mode with no rate. The success toast now names the rate applied, and a refused payout still shows its reason. The mode selector is disabled for sat-denominated contracts, where there is no conversion to have an opinion about, and its hint says the setting only applies to a payday already in the past — otherwise it reads as though it governs every payout, which it does not. Quasar UMD rules honoured: no self-closing tags, `${ }` delimiters, `:style` bindings rather than a