Pay-now on a period due next week offered "Rate on the payday" and then found a rate anyway, with only a passive hint to explain why. Two separate problems behind that. The real one: `resolve_price` tested `payday >= today` before it tested the mode, so `manual` was silently ignored for any period not already back-dated. "Pay at the rate we agreed" quietly did not, and a contract pegged to a fixed rate honoured it only on late periods. A stated rate is an instruction rather than a lookup, so it now wins outright and applies to every period. `payday` and `current` keep their old order — for a payday today or ahead there is no history to consult, so `payday` collapses into `current`, which is the only defensible answer for a date that has not happened. The cosmetic one: the dialog's hint said "ignored unless the payday is already in the past", sitting beside a control that plainly appeared to do something — and was about to become wrong anyway, since manual now always applies. It is replaced by a line that names the actual period: either "Payday 2026-09-05 has not passed, so there is no historical rate to look up — this will be priced at the current rate", or "Will convert at what BTC was worth on <date>". The dialog header now shows which payday is being settled, which was not visible at all before. The contract dialogs lose the same stale hint; their label becomes "Pricing" rather than "Price a late payday at", which no longer describes manual mode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
204 lines
6.7 KiB
Python
204 lines
6.7 KiB
Python
"""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
|
|
|
|
|
|
LIVE_RATE = 67_000.0
|
|
|
|
|
|
@pytest.fixture
|
|
def historical(monkeypatch):
|
|
"""Stub both rate sources and record what the historical one was asked."""
|
|
asked = []
|
|
|
|
async def fake_rate(day, currency):
|
|
asked.append((day, currency))
|
|
return PAST_RATE if currency == "EUR" else None
|
|
|
|
async def fake_live(amount, _currency):
|
|
return round(amount / LIVE_RATE * 1e8)
|
|
|
|
monkeypatch.setattr(services, "historical_btc_rate", fake_rate)
|
|
monkeypatch.setattr(services, "fiat_amount_as_satoshis", fake_live)
|
|
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_the_live_rate(historical):
|
|
"""No mode needs history for a payday that has not passed."""
|
|
p = price(eur(), payday=TODAY)
|
|
assert (p.currency, p.source) == ("sat", "current")
|
|
assert p.amount == round(800 / LIVE_RATE * 1e8)
|
|
assert historical == []
|
|
|
|
|
|
def test_the_live_rate_is_known_before_the_invoice(historical):
|
|
"""Payroll converts rather than handing create_invoice a fiat amount, so
|
|
the rate exists in time to go in the memo."""
|
|
p = price(eur(), payday=TODAY)
|
|
assert p.rate == pytest.approx(LIVE_RATE, rel=1e-6)
|
|
|
|
|
|
def test_a_future_payday_uses_the_live_rate(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.source == "current"
|
|
assert p.amount == round(800 / LIVE_RATE * 1e8)
|
|
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)
|
|
|
|
|
|
# --- memo ------------------------------------------------------------------
|
|
|
|
|
|
def test_memo_names_the_rate_applied():
|
|
"""18,353 sat and 14,794 sat are both "ten euro" — only the rate says
|
|
why a back-dated payout differs from today's."""
|
|
memo = services.payout_memo(eur(label="dev retainer"), PAST, PAST_RATE)
|
|
assert memo == "dev retainer — 2026-05-15 · 800 EUR @ 69,743.26 EUR/BTC"
|
|
|
|
|
|
def test_memo_prefers_the_payment_memo_over_the_label():
|
|
contract = eur(label="internal label")
|
|
contract.memo = "Salary"
|
|
assert services.payout_memo(contract, PAST, PAST_RATE).startswith("Salary — ")
|
|
|
|
|
|
def test_memo_of_a_sat_contract_has_no_rate():
|
|
contract = make_contract(currency="sat", amount=1000, label="chores")
|
|
assert services.payout_memo(contract, PAST) == "chores — 2026-05-15"
|
|
|
|
|
|
def test_memo_without_a_rate_still_states_the_fiat_amount():
|
|
assert services.payout_memo(eur(label="x"), PAST) == "x — 2026-05-15 · 800 EUR"
|
|
|
|
|
|
# --- an explicit rate is an instruction, not a lookup ----------------------
|
|
|
|
|
|
def test_manual_applies_to_a_payday_today(historical):
|
|
""" "Pay at the rate we agreed" is as meaningful for a payday now as for
|
|
one last month — the calendar must not quietly override it."""
|
|
p = price(eur(pricing_mode=PricingMode.manual, manual_rate=100_000), payday=TODAY)
|
|
assert (p.source, p.rate) == ("manual", 100_000)
|
|
assert p.amount == 800_000
|
|
|
|
|
|
def test_manual_applies_to_a_future_payday(historical):
|
|
p = price(
|
|
eur(pricing_mode=PricingMode.manual, manual_rate=100_000),
|
|
payday=date(2026, 12, 1),
|
|
)
|
|
assert p.source == "manual"
|
|
|
|
|
|
def test_manual_override_beats_a_future_payday(historical):
|
|
"""Paying next week's period early, at a stated rate."""
|
|
p = price(
|
|
eur(), payday=date(2026, 12, 1), mode=PricingMode.manual, manual_rate=50_000
|
|
)
|
|
assert (p.source, p.rate) == ("manual", 50_000)
|
|
|
|
|
|
def test_payday_mode_on_a_future_payday_falls_to_the_live_rate(historical):
|
|
"""There is no historical rate for a date that has not happened."""
|
|
p = price(eur(pricing_mode=PricingMode.payday), payday=date(2026, 12, 1))
|
|
assert p.source == "current"
|
|
assert historical == []
|